Back to Blog
Power Query
Excel
Combining Files
Data Refresh
Reporting

Combine Multiple Excel Files Into One With Power Query

One Depot's File Was in the Folder Twice and Another's Headers Had Moved Down a Row, and the August Board Pack Came Out £12,090 Light While Both Halves of It Were Wrong by Six Figures

28/09/2026
Combine Multiple Excel Files Into One With Power Query

Quick Summary

Key points from this article

  • 📁 **`Folder.Files` is recursive.** From Folder reads every file in the folder *and every subfolder under it*, so tidying last year's files into `\monthly\Archive 2025\` puts them back inside the query. `Folder.Contents` is the non-recursive one, and keeping `Folder Path` in the output is what tells you the day it happens
  • 🧾 **`Table.PromoteHeaders` promotes row 1, whatever is in it.** One title row above the headers and that file's columns come out as the title, `Column2`, `Column3` — no error, because every table has a first row and promoting it always works
  • 🕳️ **An expanded column a file does not have is null, not an error.** The expand step asks for a fixed list of names captured when you pressed the button, and Dunmore's 412 rows arrived with all six cells empty. `SUM` ignores empty cells, so £96,400 became nothing at all
  • ⚠️ **Where the type step sits decides whether a changed file is loud or silent.** Types inside the per-file function raise `The column 'Net sales' of the table wasn't found` and stop the refresh; types after the append never fail, and turn the same file into a block of nulls
  • 👯 **No money-based reconciliation can see a duplicated file.** Each depot's file carries its own control total, so a file in the folder twice is on both sides of the comparison twice and the difference stays at zero. `=COUNTA(UNIQUE(Sales[Source.Name]))` is the only check that catches it
  • 🔍 **`Source.Name` is the whole diagnosis.** One pivot — `Source.Name` down the rows, count of rows and sum of the money column — would have shown two Kesgrave lines and a Dunmore line with 412 rows and no money, in four seconds, to anyone glancing at it
Reading time: ~21 min

Thurlow Provisions runs nineteen depots, and every one of them drops the same file into the same place on the first working day of the month. The folder is \\thu-fs02\sales\monthly. The file is Depot_<name>_2026-08.xlsx: one row per product line, with Date, Depot, Product, Units, Net sales and Margin % across the top and a total row at the bottom.

Until March 2024 somebody opened all nineteen and copy-pasted them under each other. Since then there has been a query: Data → Get Data → From File → From Folder, point it at \\thu-fs02\sales\monthly, Combine & Transform, load to the Data Model, and the board pack's pivot tables sit on top of it. It takes forty seconds to refresh and it has been right every month for two and a half years.

On 3 September 2026 it was refreshed for the August board pack and it reported £1,611,140 of net sales across the nineteen depots. The real figure was £1,623,230.

£12,090 out, 0.74% low — small enough that nobody looked twice at it, and it is not one error. It is two, in opposite directions, netting off:

  • Depot_Kesgrave_2026-08.xlsx was in the folder twice, the second copy called Depot_Kesgrave_2026-08 (1).xlsx after somebody saved the emailed attachment a second time. Kesgrave's 501 rows were appended twice and its £84,310 was counted twice.
  • Depot_Dunmore_2026-08.xlsx had been rebuilt by a new assistant with a title row at the top, so the column headers sat on row 2 instead of row 1. All 412 of Dunmore's rows arrived in the combined table with every column empty. Its £96,400 was counted as nothing at all.

£84,310 too much and £96,400 too little. The total was believable because the two mistakes were nearly the same size.

What acted on it was not the board pack. The September replenishment plan for short-life lines is built from the same query, on a three-month average, so Kesgrave was ordered against a demand that had never existed and Dunmore against one that had disappeared. £18,700 of short-life stock was written off at Kesgrave over the first fortnight of September. Dunmore had gaps on 31 lines for nine days and lost £26,400 of sales that it can show from its own till data. On 8 September the Dunmore manager rang to ask why his September order was a third of normal, and that is how anyone found out. The August board pack was restated on 16 September.

The query did exactly what it was told. Combine everything in this folder is three claims — combine, everything, and this folder — and a refreshed query never restates any of them. It gives you one table with no statement attached about what went into it.


1) The Folder as the Query Saw It

This is the provenance table nobody was loading: one row per file the query actually read, with the rows and the money each file contributed to the combined table. Every number in it comes from the August refresh.

Twenty Files, Nineteen Depots

The August refresh, file by file: the rows and the net sales each workbook contributed to the combined table. Kesgrave is in the folder twice and appears twice. Dunmore is in it once and contributed 412 rows with every column empty. The two faults are £84,310 too much and £96,400 too little, which is why the total at the bottom looks like a normal month.

ABCDEF
1
File in the folder
Depot
Rows appended
Net sales in the combined table
Header row found on
What the combine did
2
Depot_Ardleigh_2026-08.xlsx
Ardleigh
486
92140
Row 1
Read as intended
3
Depot_Blaydon_2026-08.xlsx
Blaydon
372
71880
Row 1
Read as intended
4
Depot_Cranfield_2026-08.xlsx
Cranfield
518
104260
Row 1
Read as intended
5
Depot_Dunmore_2026-08.xlsx
Dunmore
412
0
Row 2 — a title above it
412 rows, every column null
6
Depot_Earlsfield_2026-08.xlsx
Earlsfield
459
88950
Row 1
Read as intended
7
Depot_Framlingham_2026-08.xlsx
Framlingham
336
63470
Row 1
Read as intended
8
Depot_Garstang_2026-08.xlsx
Garstang
401
79320
Row 1
Read as intended
9
Depot_Halewood_2026-08.xlsx
Halewood
574
118640
Row 1
Read as intended
10
Depot_Ilminster_2026-08.xlsx
Ilminster
318
58210
Row 1
Read as intended
11
Depot_Kesgrave_2026-08.xlsx
Kesgrave
501
84310
Row 1
Read as intended
12
Depot_Kesgrave_2026-08 (1).xlsx
Kesgrave
501
84310
Row 1
The same 501 rows, appended again
13
Depot_Longridge_2026-08.xlsx
Longridge
445
86730
Row 1
Read as intended
14
Depot_Marchwood_2026-08.xlsx
Marchwood
390
74560
Row 1
Read as intended
15
Depot_Northfleet_2026-08.xlsx
Northfleet
612
126480
Row 1
Read as intended
16
Depot_Ormskirk_2026-08.xlsx
Ormskirk
428
81290
Row 1
Read as intended
17
Depot_Penkridge_2026-08.xlsx
Penkridge
357
66940
Row 1
Read as intended
18
Depot_Redgrave_2026-08.xlsx
Redgrave
303
55120
Row 1
Read as intended
19
Depot_Stourton_2026-08.xlsx
Stourton
536
109870
Row 1
Read as intended
20
Depot_Tattenhall_2026-08.xlsx
Tattenhall
369
69450
Row 1
Read as intended
21
Depot_Wetherby_2026-08.xlsx
Wetherby
494
95210
Row 1
Read as intended
22
23
20 files
19 depots
8812
1611140
Real total: £1,623,230

fxCells with formulas are highlighted in green

Hover over formula cells to see the formula and highlight referenced cells

Two rows carry the whole incident. Kesgrave appears twice, with 501 rows and £84,310 each time, because there were two files and the combine has no opinion about that. Dunmore appears once with 412 rows and nothing in them.

Note what the totals do. 8,812 rows is a plausible-looking number next to last month's 8,270. £1,611,140 is a plausible-looking number next to last month's £1,588,400. Nothing in the combined table is out of range, because the two errors were both large and pointed in opposite directions.

🎯 Scenario: Build this table for your own folder query before you read on. In the query editor, right-click the combined query → Reference, then Group By on Source.Name with two aggregations: Count Rows, and Sum of your money column. Load it to a worksheet next to the report. It costs one extra query and it is the only thing in the workbook that knows which files the numbers came from.


2) Folder.Files Includes Subfolders

From Folder generates Folder.Files("\\thu-fs02\sales\monthly"), and Folder.Files is recursive: it returns every file in that folder and in every folder underneath it, to any depth. The Folder Path column is there to tell you so, and it is usually the first column people remove.

This is the cheapest way to double a year. Somebody tidies the share by making \monthly\Archive 2025\ and dragging last year's twelve files into it — a tidy-up that moves nothing out of the query's reach. The next refresh appends 2025 to 2026, and whether that shows up depends entirely on whether the report filters by a real date or by a month name.

The alternative is Folder.Contents, which returns only what is directly in the folder, with subfolders as rows you can ignore. You can switch a query to it by hand. The other way is to keep Folder.Files and filter:

= Table.SelectRows(Source, each [Folder Path] = "\\thu-fs02\sales\monthly\")

One habit is worth more than either: keep Folder Path in the output, and put =COUNTA(UNIQUE(Sales[Folder Path])) next to the report. It should be 1. When somebody tidies the share, it becomes 2 the same day.

🎯 Scenario: Open your own folder query and look at its steps. If the first thing after Source removes columns, click Source and read the Folder Path column. Ask whether you can promise there will never be a subfolder in there — not today, but in eighteen months, after someone who has never heard of this query decides the folder is untidy.


3) The Combine Files Button Writes Four Queries and a Function

Pressing the double-arrow on the Content column is a bigger act than it looks. Excel writes a Helper Queries group containing:

QueryWhat it is
Parameter1A parameter typed as binary, standing in for "a file"
Sample FileThe first file in the folder after your filters, as a binary
Transform Sample FileA normal query with steps you can see and edit, running against the sample
Transform FileA function: the sample's steps, wrapped so they can be applied to any file

And in the main query, two steps that do the work:

= Table.AddColumn(#"Filtered Hidden Files1", "Transform File", each #"Transform File"([Content]))
= Table.ExpandTableColumn(#"Invoked Custom Function", "Transform File", {"Date", "Depot", "Product", "Units", "Net sales", "Margin %"}, …)

Three consequences follow, and they are the subject of the next three sections.

The sample is one file, and it is the first one. Every rule the transform contains was inferred from a single workbook. Rename a file so that a different one sorts first, and the sample changes.

Transform Sample File is where you edit. Changing the function directly is possible and a bad habit; the sample query exists so you can see what you are doing, and the function follows it.

Filtered Hidden Files1 is Table.SelectRows(Source, each [Attributes]?[Hidden]? <> true), and it is the reason the Combine button survives a colleague who has one of the source files open. Excel writes a ~$Depot_Ardleigh_2026-08.xlsx lock file next to it; that file is hidden, and hidden files are gone before the function ever sees them. A hand-rolled Folder.Files + Excel.Workbook query has no such step, which is why hand-rolled folder queries fail on the one morning somebody is still editing their file.

🎯 Scenario: In your own workbook open Queries & Connections, expand Helper Queries, and click Transform Sample File. Read its steps out loud. That list, and nothing else, is what is being applied to all nineteen files.


4) Navigation Happens by Name

The first step of Transform Sample File is almost always this:

Source = Excel.Workbook(Parameter1, null, true),
Sales_Sheet = Source{[Item="Sales",Kind="Sheet"]}[Data]

That is a lookup by name. Item="Sales" means the tab called Sales, exactly, case and spaces included. A depot that renames its tab Sales 2026, or sends a workbook where the tab is still Sheet1, produces:

Expression.Error: The key didn't match any rows in the table.

This one is loud, and loud is cheap: the refresh stops, nobody publishes anything, and you spend ten minutes finding the file. The positional alternative takes the first sheet whatever it is called:

Sales_Sheet = Source{0}[Data]

Which of the two you want is a real decision. Item="Sales" refuses to read the wrong tab and stops when it cannot find the right one. Source{0} never stops, and cheerfully reads a Notes tab that somebody dragged to the front. In a folder of files from nineteen different people, the name lookup is usually right, because the failure you can see beats the one you cannot.

There is a third Kind. Excel.Workbook lists sheets, named ranges and Tables, so if every depot's data lives in a Table called tblSales, Source{[Item="tblSales",Kind="Table"]}[Data] is better than either: a Table carries its own headers and its own extent, so no promotion step is needed and rows typed under it are inside it.

🎯 Scenario: Check what your transform navigates to. If it is Kind="Sheet", the tab name is now part of your monthly process, whether or not the people sending you files know that. Send them a template with the tab already named, and say why.


5) Promote Headers Runs Once Per File

The second step is Table.PromoteHeaders, and this is where Dunmore went.

#"Promoted Headers" = Table.PromoteHeaders(Sales_Sheet, [PromoteAllScalars=true])

Table.PromoteHeaders promotes the first row of that file, whatever it happens to contain. It is not looking for your headers. It does not compare against the sample. It takes row 1 and makes it the column names.

Dunmore's assistant had rebuilt the file from the July one and typed a title across the top — Dunmore — August 2026 in A1, the headers pushed down to row 2. So for that file, and only that file, the promoted column names came out as Dunmore — August 2026, Column2, Column3 and so on, and the real headers became the first row of data.

Nothing failed. Table.PromoteHeaders cannot fail — every table has a first row.

🎯 Scenario: Ask your transform what it assumes about row 1. If files come from other people, add a step that finds the header row instead of trusting it: filter out rows where your key column is null before promoting, or use Table.Skip with a condition. Even a plain Table.SelectRows that drops rows where every column is null is worth its place.


6) Expand Is a Fixed List of Column Names

Here is why a promoted title row produces nothing instead of an error.

Table.ExpandTableColumn takes an explicit list of column names, captured when you pressed the button:

{"Date", "Depot", "Product", "Units", "Net sales", "Margin %"}

For every nested table it looks for those six names. Dunmore's nested table had six columns too, called Dunmore — August 2026, Column2, Column3, Column4, Column5 and Column6. None of them matched. A column that is asked for and not found expands as null — not as an error, not as a warning, not as a shorter table. Dunmore contributed 412 rows, every cell of them empty, and SUM ignores empty cells as a matter of policy.

The same mechanism, with a smaller cause, is a column somebody renamed: Net sales → Net Sales in one file is a column of nulls for that file. The expand list is case-sensitive, like every other name comparison in M.

And the list is fixed, which cuts the other way too. When all nineteen depots start sending a Promo code column next January, the expand step will keep asking for the same six names and that column will not appear. Nothing will tell you. You re-open the expand step and tick it.

This is also the step that decides whether your folder query fails loudly or quietly, because of where the types get set. When Combine Files writes the transform, it puts a Changed Type step inside the per-file function:

= Table.TransformColumnTypes(#"Promoted Headers", {{"Date", type date}, {"Net sales", type number}, …})

Naming a column that does not exist in that file raises Expression.Error: The column 'Net sales' of the table wasn't found, and you get an error you cannot miss. Thurlow's analyst had deleted that step in March 2025, for a defensible reason: one depot had sent a file with an empty Margin % column, the typing step failed, and the whole refresh died at 07:40 on a board morning. Moving the type change to after the append fixed that and is advice you will find in a hundred places, including from people who know exactly what they are doing.

It is still the trade. Types inside the function: a file that changed shape fails, visibly, and you fix it. Types after the append: nothing fails, and the file that changed shape becomes a block of nulls in a table of nine thousand rows.

🎯 Scenario: Decide which of the two you want, deliberately, and write it down next to the query. If you choose types after the append, you owe the null check in section 11 — =ROWS(Sales)-COUNT(Sales[Net sales]) — somewhere visible on the report.


7) The Copy With "(1)" In Its Name

Kesgrave's file was in the folder twice because someone opened the email a second time and Windows did what Windows does: Depot_Kesgrave_2026-08 (1).xlsx, saved alongside the first one.

There is no mechanism in Power Query that objects to this. Both files match the extension filter. Both have a Sales tab, headers on row 1, 501 good rows. The combine appended both because both were there, and it was right to.

What makes it expensive is that no total-based check can find it. This is the part worth slowing down for. Every depot's file ends with its own total row — a control total, produced by the depot, independent of your query. So the obvious reconciliation is to keep those total rows in a second query and compare:

=SUM(Sales[Net sales]) - SUM(Control[Depot total])

It found neither. Dunmore's total row went through the same mis-promoted file as its data, so it arrived null in Control exactly as the data arrived null in Sales: both sides lost £96,400 and the difference stayed at zero. And Kesgrave's duplicate file contains a total row of its own, so the duplicate is counted twice in Sales and twice in Control, the two sides move together, and the difference stays at zero again.

A reconciliation that compares the combined table against control totals drawn from the same folder cannot see either fault, because both faults are on both sides of it.

This is the general shape of the thing. Every check drawn from inside the combined table compares the folder with itself. The only check that catches a duplicate file is a check on the folder: how many files, against how many there should be.

=COUNTA(UNIQUE(Sales[Source.Name]))     19 depots, 19 files          returned 20

🎯 Scenario: Write down, in a cell, how many files you expect this month. Nineteen depots, nineteen files. Then put the count next to it. Two cells and a subtraction, and it is the one check in this article that has no substitute.


8) Total Rows Inside Each File Append as Data

Each depot's file ends with a total row, and that row has no idea it is special. Excel.Workbook reads the whole used range, so unless something removes it, every one of those nineteen total rows is appended as a data row and the combined table double-counts every depot by exactly 100%.

At Thurlow this was handled on day one, because the error is too big to miss: the August total would have come out at £3.2m. It is only invisible when the total row is partial — a subtotal per product group, a "Returns" line under the data, a note typed two rows below the last line — and then it is a few percent, in the same territory as the rest of this article.

The robust filter is on the key column, not on the word "Total":

= Table.SelectRows(#"Promoted Headers", each [Date] <> null and [Product] <> null)

That survives a depot writing TOTAL, Total for month, or nothing at all in column A, because it is asking whether the row is a transaction, not whether it is labelled as one.

🎯 Scenario: In the query editor, click the combined query, click the filter arrow on your key column and look for (null) in the list. Anything there is a row that is in your table and is not a transaction. While you are there, sort descending on the money column and read the top five rows: a total row that slipped through is always near the top.


9) Source.Name Is the Only Thing That Knows Where a Row Came From

The Combine button keeps the file name and renames it Source.Name, and it is the most valuable column in the table. Everything in this article is diagnosable in one pivot: Source.Name down the rows, count of rows and sum of money as the values.

That pivot would have shown, in August, two Kesgrave lines and a Dunmore line with 412 rows and no money, in about four seconds.

Keep it. Keep Folder Path too, and if the file name carries the month, split it into a proper File month column with Extract → Text Between Delimiters so you can check the month is the one you think you are reporting. A folder query that has dropped Source.Name is a table that cannot answer the question "which file did this come from", and after that, every investigation is opening nineteen workbooks by hand.

🎯 Scenario: Build the provenance pivot now and leave it on a sheet called Check, next to the report. Rows: Source.Name. Values: count of rows, sum of the money column. Refresh it with everything else. A person glancing at nineteen lines will see two called Kesgrave without having to be told what to look for.


10) Refresh, and What "Loaded" Means

The query was right for two and a half years and wrong in August, and it was wrong the instant it refreshed. Three things about refresh are worth stating plainly.

Refresh All is not automatic. Ctrl Alt F5 refreshes every query and every pivot in the workbook. Opening the file refreshes nothing unless the connection has Refresh data when opening the file ticked, in Queries & Connections → right-click → Properties. A folder query in a file nobody has refreshed is showing you the folder as it was on some past date, which is exactly as wrong as the folder being incomplete, and quieter.

Background refresh reorders your morning. With Enable background refresh on, the refresh returns control to you before it has finished, so a pivot read too quickly is read against last month's data. On a report you are about to send, turn it off.

A connection-only query has no output to check. If the combined query loads straight into the Data Model and only pivots consume it, there is no table anywhere to run =ROWS() against. That is a fine architecture and it needs the checks in the next section loaded as their own small queries, or as measures, rather than as formulas beside a table that does not exist.

One more, because it wastes a whole morning when it happens: if the folder path comes from a cell rather than being typed into the query, you will meet Formula.Firewall: Query 'X' references other queries or steps, so it may not directly access a data source. It is a privacy-level rule, not a bug. Data → Get Data → Query Options → Privacy → Ignore the Privacy Levels for that workbook, or restructure so the path is a parameter.

🎯 Scenario: Open Queries & Connections, right-click your folder query, Properties, and read the two tick boxes. Then look at the bottom of the query pane at the row count and the timestamp: "8,812 rows loaded" and a date. Both of those are facts about the last refresh, and the row count is one of the numbers you should be able to predict before you read it.


11) Nineteen Files, Nineteen Depots: the Checks That Would Have Caught It

Each of these is one cell, against the combined table loaded as Sales. Run them after every refresh, on the sheet the report lives on.

CheckWhat it should returnWhat it returned in August
=COUNTA(UNIQUE(Sales[Source.Name]))19 — one file per depot20
=COUNTA(UNIQUE(Sales[Depot]))19 — every depot named in its own rows18
=ROWS(Sales)-COUNT(Sales[Net sales])0 — no row without a number412
=ROWS(Sales)within a few per cent of last month's 8,2708,812 — 6.6% up, on flat sales
=COUNTA(UNIQUE(Sales[Folder Path]))1 — one folder, no subfolders1
=SUMPRODUCT(--ISNUMBER(SEARCH("(",Sales[Source.Name])))0 — no file name carrying a copy marker501
=SUM(Sales[Net sales])-SUM(Control[Depot total])0 — the table against the files' own totals0

Three notes on that table, and they matter more than the formulas.

The first check is the only one that catches the duplicate. Everything drawn from the money — including the last row, which looks like the most rigorous check in the list — compares the folder against itself, and a file that is in the folder twice is in both sides of that comparison twice.

The last row is there to make that visible. It returned zero, all month, on a table that was £12,090 wrong. It is not a bad check; it catches transcription, truncation and a file that failed to load. It cannot catch this, and a check that returns zero for the wrong reason is worse than no check, because of what people conclude from it.

The fifth is in the list because of June, not August. Somebody made \monthly\Archive 2025\ on 12 June and the next refresh pulled twelve extra files. That one was found the same day — the total went up by 68% — and it is the reason Folder Path is still in the output.


12) Twelve Traps

  1. Folder.Files is recursive. A subfolder created inside your source folder is inside your query. Folder.Contents is the non-recursive one.
  2. The sample file is the first file, so every rule in the transform was inferred from one workbook that happened to sort first. Rename the files and the sample changes.
  3. Item="Sales",Kind="Sheet" is an exact, case-sensitive name lookup. A renamed tab stops the refresh. Source{0}[Data] takes the first sheet instead and never stops, including when the first sheet is the wrong one.
  4. Table.PromoteHeaders promotes row 1, whatever is in it. One title row above the headers and that file's columns are named after the title.
  5. Expanding a column that a file does not have gives null, not an error. A renamed or mis-promoted column is a block of empty rows, and SUM skips empty cells without comment.
  6. The expand list is fixed at the moment you press the button. A column every file starts sending next year will not appear until you edit the step.
  7. Types inside the per-file function fail loudly; types after the append fail silently. Both are defensible. Only one of them needs the null check.
  8. Extension filters are case-sensitive in M. [Extension] = ".xlsx" does not match .XLSX. Use Text.Lower([Extension]).
  9. Total rows inside each file append as data. Filter on a key column being non-null, not on the word "Total", which every depot spells differently.
  10. ~$ lock files are hidden, so the Combine button's Filtered Hidden Files step removes them and a hand-written Folder.Files query does not.
  11. A duplicate file is invisible to every money-based reconciliation, because the control totals are duplicated along with the data. Count the files.
  12. Nothing refreshes on open unless you tick it, and with background refresh on, a pivot read straight after a refresh may be read before it finished.

The lesson Thurlow took out of August was not about Power Query, and it was certainly not that the query had been a mistake — nineteen files by hand was worse, and wrong more often.

It was that a combined table has no provenance. Nine thousand rows arrive looking identical, and which file each row came from, how many files there were, and which of them were read the way you meant them to be read are questions the table does not answer unless you make it. The fix is not a better transform. It is four cells and a pivot on Source.Name, refreshed with everything else, saying how many files there were and how many rows and pounds each one brought — so that the day somebody saves an attachment twice, or types a title above the headers, the file that changed is the one you are looking at.

Share this article:
Back to Blog