๐ Procurement Data ยท 9 min read
Supplier Price List Comparison in CSV: The Unit Trap
Two supplier price lists never arrive in the same shape: the columns are named differently, the items are spelled differently, and the prices are quoted per different unit. Any comparison has to normalise three things โ the item key, the unit, and the currency โ before it compares a single number. Skip the unit step and the answer can reverse direction. On our five-item test it did exactly that: comparing a price per sheet against a price per square metre showed one supplier 181.6% more expensive, while the converted figures made the same supplier 6.1% cheaper.
Why the two lists never line up
RFC 4180 defines the shape of a CSV record but says nothing about what a column means โ no required column names, no types, no unit. Everything that would make two files comparable lives outside the format, in conventions each supplier invents for itself. Three differences account for nearly all of the breakage:
| Difference | List A | List B |
|---|---|---|
| Column name | Item, Unit price (USD/m2) | Description, Price (USD/sheet) |
| Item spelling | Aluminium ceiling panel 600x600 | aluminium ceiling panel 600x600 |
| Unit | per square metre | per sheet, or per m2, in the same column |
None of that is an error by either supplier. It is what happens when two people describe the same product to two different customers.
What we measured
We wrote two lists: supplier A with five items priced in USD per m2, supplier B with four of the same items priced per sheet or per m2, spelled in lower case with a hyphen variation. Read and merged with pandas 2.3.3 on Python 3.13.5, 2026-11-02. Fixtures and script live in seo_geo_ops/nc_measure_20261102/.
| Merge strategy | Rows matched | What was left over |
|---|---|---|
| Exact string equality | 2 of 5 | Case and spacing differences killed the rest |
| Trim + lowercase + collapse spaces | 3 of 5 | 2 items only in A, 1 only in B โ and one pair still unmatched: clip-in ceiling tile vs clip in ceiling tile |
| Unit comparison, unconverted | โ | 181.6% gap in the wrong direction |
The unit row is the one worth staring at. Supplier B quotes 27.60 per sheet; supplier A quotes 9.80 per square metre. Subtracting those two numbers is meaningless, yet it is the first thing any spreadsheet does when the columns are placed side by side โ and it reported B as 181.6% more expensive. Divide by 3 mยฒ per sheet and B becomes 9.20 per mยฒ, which is 6.1% cheaper than A.
| Item | A (USD/m2) | B raw | B (USD/m2) | Gap |
|---|---|---|---|---|
| Aluminium ceiling panel 600x600 | 9.80 | 27.60 / sheet | 9.20 | โ6.1% |
| Aluminium baffle 50x100 | 14.50 | 15.10 / m2 | 15.10 | +4.1% |
| Aluminium soffit panel | 11.30 | 12.05 / m2 | 12.05 | +6.6% |
Three comparable items, and the winner depends on the line: B is cheaper on the 600x600 panel and more expensive on the other two. That is the normal shape of a real comparison, and it is invisible until the units match.
Step 1: normalise the item key, then accept its limits
Trimming, lowercasing and collapsing internal spaces is the cheap 80%:
key = col.str.strip().str.lower().str.replace(r"\s+", " ", regex=True)
It is not enough, and pretending otherwise is how comparisons quietly under-report. Our clip-in versus clip inpair survived normalisation because a hyphen is a real character. Fixing that means a mapping table you maintain by hand โ a deliberate list of "these two strings are the same product". That table is the actual procurement knowledge; keep it in the repo next to the script so the next comparison inherits it.
Do not try to solve this with fuzzy matching on the first pass. A 90% string similarity threshold will happily merge 600x600 with 600x1200, and a wrong merge is worse than an unmatched row because it looks like a finding.
Step 2: convert to one unit before comparing
Pick one unit per product family and convert everything into it. Common conversions in building materials: sheet โ mยฒ via the sheet area, box โ mยฒ via the pack coverage, pallet โ mยฒ via the packing density, linear metre โ mยฒ via the panel width. Write the conversion factor in its own column so a reviewer can see where a number came from.
SHEET_M2 = 3.0 b["usd_per_m2"] = np.where(b["Unit"] == "sheet", b["price"] / SHEET_M2, b["price"])
Two rules that prevent most mistakes here: never mix units inside one column (list B did exactly that, with a Unit column carrying both sheet and m2), and never derive the factor from a single sample. One sheet being 3 mยฒ does not mean every product from that supplier is 3 mยฒ.
Step 3: unmatched rows are questions, not zeroes
In our test two items existed only in A and one only in B. A spreadsheet that sorts by "cheapest" will silently drop them, and the buyer reads that as "supplier B could not beat us on those lines". What actually happened is that B never quoted them. Those are three follow-up actions with a date, not three data points.
Keep the merge indicator โ pandas returns it with how="outer", indicator=Trueโ and print the three counts before any price sorting: both, only A, only B. If "only A" is large, the comparison is not finished, it is a draft.
Step 4: keep the sheet auditable
A comparison that cannot be re-checked in six months is worth very little. These columns are the minimum:
- item key as it appears in each source (verbatim, not normalised)
- raw price and raw unit from each supplier
- converted unit price, plus the factor used
- gap as a percentage, computed on converted values only
- source file name and the date the list was issued
The last one decides whether a 6% gap is a real discount or a comparison between last quarter's list and this quarter's. Two price lists without dates are two rumours.
What a price list cannot tell you
Unit price is one line of a landed cost. A price list says nothing about freight and container loading, minimum order quantity, packing density per pallet, tolerance and surface finish, payment terms, or lead time โ and those move the total further than a 6% unit difference usually does. Model the landed cost with identical quantities for both suppliers before deciding, and treat the unit-price comparison as the screen that decides who gets the detailed request.
Two mechanical checks before you even get that far: leading zeros and long numeric codes in product references must stay text (keeping leading zeros covers the fix), and the quantity column must be numeric rather than text with thousands separators โ a quantity that silently stayed a string will not sum. If the numbers arrive from a PDF, extracting the table into CSV first is more reliable than retyping it.
Frequently Asked Questions
Why do my two supplier price lists not line up?
Because they were written for different readers. One calls the column Item, the other Description; one writes Aluminium ceiling panel 600x600, the other lower case with different spacing, or Clip-in instead of Clip in. In our test a merge on the exact strings matched 2 of 5 rows; after stripping spaces, collapsing double spaces and lowercasing, it matched 3. The remaining failure was a hyphen, which no amount of normalisation fixes automatically.
How do I compare a price per sheet with a price per square metre?
Convert both to one unit before comparing anything. If a sheet covers 3 square metres, a price of 27.60 per sheet is 9.20 per square metre. Skipping that step compares 27.60 against 9.80 and reports the second supplier as 181.6% more expensive; after conversion the same pair is 6.1% cheaper. The direction of the answer flips, so unit handling is not a detail.
An item is on one list but not the other. Is the missing one cheaper?
No โ it is an unanswered question. A blank means the supplier did not quote it, which can mean they cannot make it, did not notice it, or need a drawing before pricing it. Treat every unmatched row as a follow-up item with an owner and a date, not as a zero. In our five-item test two items existed only in list A and one only in list B, so a third of the comparison was really a question list.
Is the lower unit price always the better supplier?
No, because a price list is not a landed cost. It says nothing about freight, minimum order quantity, packing (how many sheets per pallet), tolerance and surface finish, payment terms, or lead time. Two suppliers can differ by 6% on unit price and by 40% on landed cost once packing density and payment terms are applied. Compare unit price first, then model the landed cost with the same quantities for both.
What columns should a comparison sheet keep?
Keep the item key exactly as it appears in each source, the raw price and unit from each source, the normalised unit price, the gap as a percentage, and the source file name and date. The last two are what make the sheet auditable: without them nobody can tell whether a 6% gap came from a real discount or from comparing last quarter's list with this quarter's.
Can I do this comparison without code?
Yes, in a spreadsheet or a browser tool, with one rule: never compare two numbers until their units match, and keep a column showing the unit you converted from. A browser analyser is useful for the first step because it shows the parsed row count and column names of each file, which is where mismatched headers and swallowed header rows surface before any arithmetic happens.
Sources
- RFC 4180, Common Format and MIME Type for CSV Files, section 2: records consist of fields separated by commas, and fields containing commas must be quoted. The format defines no column names, types or units โ which is why two valid CSVs can be incomparable without either being wrong. Retrieved 2026-11-02.
- pandas 2.3.3 documentation, pandas.merge:
how="outer"keeps unmatched keys from both sides andindicator=Trueadds a_mergecolumn with the valuesleft_only,right_onlyandboth;onrequires identically named keys, whileleft_on/right_onmatch differently named columns. Retrieved 2026-11-02. - Local measurement, 2026-11-02, Python 3.13.5 / pandas 2.3.3, fixtures and script at
seo_geo_ops/nc_measure_20261102/: list A 5 items priced USD/m2, list B 4 items priced per sheet or per m2; exact-string merge matched 2 rows; strip+lower+whitespace normalisation matched 3, leaving 2 only-in-A and 1 only-in-B and one pair unmatched on a hyphen; comparing 27.60/sheet against 9.80/m2 gave +181.6%, and converting at 3 m2 per sheet gave 9.20/m2, i.e. โ6.1%.
Check Both Files Before You Compare Them
The analyzer shows the parsed rows, the detected delimiter and the column types for each list โ so a swallowed header or a quantity column that stayed text is caught before any number gets subtracted from another number.
Related reading
Data Cleaning โ other guides that pair well with this one.
- Why Is My CSV File Not Uploading? 8 Causes and Fixes
- CSV Delimiter Change: Rewrite the File Without Breaking Your Data
- Convert CSV to PDF
- Convert CSV to Excel Without Excel
Browse all guides in the NoCodeCSV blog.