Article type: How-to guide (one task: turn a list of company names into Companies House numbers, statuses and filing due dates, ready for a mail merge)
Prerequisites: Python 3 with
pandas(pip install pandas), and about 500 MB of disk space for the download (pandas reads the zip directly, so you never unpack the 2.8 GB CSV). No API key or account.Tested on: 14 November 2026
If you look after a list of client companies, the list is usually names, typed by a person: "Marks & Spencer Group plc", "BP p.l.c.", "Sainsburys". Companies House works on company numbers. Before you can check anyone's confirmation statement or accounts date, you need the number, the registered name and the status, and you need to know which rows didn't match.
All the data in this post comes from Companies House, the registrar of companies. It publishes a free monthly snapshot of every live company on the register, the Free Company Data Product. One row per company: registered name, number, status, company type, SIC codes, the next accounts due date and the next confirmation statement due date. No key and no sign-up.
This post matches a name list against that snapshot, adds the due dates, flags the ones to chase, and gives you a sheet you can mail-merge from. Then it shows why you should confirm each flagged company on the live register before anything goes to a client.
Step 1: download the snapshot
On the download page, take "Company data as one file". On 14 November 2026 that was BasicCompanyDataAsOneFile-2026-11-01.zip (471 MB; the CSV inside is 2.8 GB). Companies House says the snapshot is compiled up to the end of the previous month and published within 5 working days of month end. You don't need to unzip it.
Why the bulk file and not one request per name? The free REST API needs a key and one request per company. With the whole register in one file, an exact match on the registered name is a plain join.
Step 2: match the names and add the dates
Save this as ch_match.py next to the zip, and replace the Client list with your own (or read it from your spreadsheet with pd.read_excel):
import re import pandas as pd SNAPSHOT = "BasicCompanyDataAsOneFile-2026-11-01.zip" # free download, no key TODAY = pd.Timestamp("2026-11-14") # use pd.Timestamp.today() in real life WINDOW = pd.Timedelta(days=60) # "due soon" = within 60 days clients = pd.DataFrame({"Client": [ "Tesco PLC", "BP p.l.c.", "Unilever plc", "Marks & Spencer Group plc", "Rolls-Royce Holdings plc", "Vodafone Group Public Limited Company", "J Sainsbury plc", "Sainsburys", "Barclays PLC", "Tesco", ]}) COLS = ["CompanyName", "CompanyNumber", "CompanyStatus", "CompanyCategory", "Accounts.NextDueDate", "Accounts.LastMadeUpDate", "ConfStmtNextDueDate", "ConfStmtLastMadeUpDate", "SICCode.SicText_1"] def norm(name): """Same company, different spelling -> same key.""" n = str(name).upper().replace("&", " AND ") n = re.sub(r"\bP\.?\s?L\.?\s?C\.?(?=\W|$)", "PLC", n) n = re.sub(r"[^A-Z0-9 ]", " ", n) # drop punctuation n = re.sub(r"\bPUBLIC LIMITED COMPANY\b", "PLC", n) n = re.sub(r"\bLIMITED\b", "LTD", n) return " ".join(n.split()) wanted = {norm(c) for c in clients["Client"]} first_words = {w.split()[0] for w in wanted} hits, same_word, rows_read = [], {}, 0 for chunk in pd.read_csv(SNAPSHOT, dtype=str, chunksize=500_000, keep_default_na=False, usecols=lambda c: c.strip() in COLS): chunk.columns = chunk.columns.str.strip() # some headers start with a space rows_read += len(chunk) chunk["key"] = chunk["CompanyName"].map(norm) hits.append(chunk[chunk["key"].isin(wanted)]) first = chunk["key"].str.split(" ", n=1).str[0] for w in first_words: same_word[w] = same_word.get(w, 0) + int((first == w).sum()) found = pd.concat(hits) print(f"{rows_read:,} live companies read from {SNAPSHOT}") clients["key"] = clients["Client"].map(norm) sheet = clients.merge(found, on="key", how="left") day = lambda s: pd.to_datetime(s, format="%d/%m/%Y", errors="coerce") sheet["CS next due"] = day(sheet["ConfStmtNextDueDate"]) sheet["Accounts next due"] = day(sheet["Accounts.NextDueDate"]) sheet["Chase?"] = (sheet[["CS next due", "Accounts next due"]] <= TODAY + WINDOW).any(axis=1) sheet["Link"] = "https://find-and-update.company-information.service.gov.uk/company/" + sheet["CompanyNumber"].fillna("") sheet.loc[sheet["CompanyNumber"].isna(), "Link"] = "" sheet["Note"] = "" for i, r in sheet[sheet["CompanyNumber"].isna()].iterrows(): w = r["key"].split()[0] sheet.at[i, "Note"] = f"no exact match; {same_word.get(w, 0):,} live names start with {w}" out = sheet.rename(columns={"CompanyName": "Registered name", "CompanyNumber": "Number", "CompanyStatus": "Status"})[ ["Client", "Registered name", "Number", "Status", "CS next due", "Accounts next due", "Chase?", "Link", "Note"]] for c in ["CS next due", "Accounts next due"]: out[c] = out[c].dt.strftime("%Y-%m-%d") out = out.fillna("") out.to_csv("clients_matched.csv", index=False) # open in Excel, or use as a mail-merge source print(out.drop(columns=["Link"]).to_string(index=False)) Output on 14 November 2026 (it took about 40 seconds on our machine):
5,711,779 live companies read from BasicCompanyDataAsOneFile-2026-11-01.zip Client Registered name Number Status CS next due Accounts next due Chase? Note Tesco PLC TESCO PLC 00445790 Active 2027-07-02 2027-08-26 False BP p.l.c. BP P.L.C. 00102498 Active 2027-05-19 2027-06-30 False Unilever plc UNILEVER PLC 00041424 Active 2027-06-23 2027-06-30 False Marks & Spencer Group plc MARKS AND SPENCER GROUP P.L.C. 04256886 Active 2027-08-06 2027-09-30 False Rolls-Royce Holdings plc ROLLS-ROYCE HOLDINGS PLC 07524813 Active 2027-10-13 2027-06-30 False Vodafone Group Public Limited Company VODAFONE GROUP PUBLIC LIMITED COMPANY 01833679 Active 2027-08-29 2027-09-30 False J Sainsbury plc J SAINSBURY PLC 00185647 Active 2027-08-08 2027-09-06 False Sainsburys False no exact match; 2 live names start with SAINSBURYS Barclays PLC BARCLAYS PLC 00048839 Active 2026-11-15 2027-06-30 True Tesco False no exact match; 87 live names start with TESCO clients_matched.csv has the same columns plus Link, the company's page on the Companies House service.
Why it's built this way:
- Names are normalised before matching. The register has "BP P.L.C.", "MARKS AND SPENCER GROUP P.L.C." and "VODAFONE GROUP PUBLIC LIMITED COMPANY".
norm()turns&into AND, all PLC forms into PLC and LIMITED into LTD, and drops punctuation, so "Marks & Spencer Group plc" and "BP p.l.c." find their companies. - Only exact matches after normalising. No fuzzy matching. A fuzzy match that picks the wrong company is worse than a blank, because the blank gets looked at.
- Unmatched rows say why. "Tesco" is a trading name, not a registered name, and 87 live company names start with TESCO. Picking one would be a guess. The note tells you to fix the row by hand (here, to TESCO PLC).
- Only the columns you need are read, in chunks of 500,000 rows, so memory stays small.
- The link is built from the number. The snapshot has a
URIcolumn, but on 14 November 2026 itsbusiness.data.gov.uklinks returned HTTP 500. Thefind-and-update.company-information.service.gov.uk/company/{number}page works.
Step 3: confirm before you chase
Look at Barclays in the output: the snapshot says its confirmation statement is due on 15 November 2026, so the script flags it. Here's its live page on the same day:
# One company's live page on the Companies House service (what the Link column opens) curl -s https://find-and-update.company-information.service.gov.uk/company/00048839 \ | sed 's/<[^>]*>/ /g' | tr -s ' \n' ' ' \ | grep -o 'Confirmation statement.*Last statement dated [0-9]* [A-Za-z]* [0-9]*' Confirmation statement Next statement date 27 October 2027 due by 10 November 2027 Last statement dated 27 October 2026 Barclays had already made its statement, dated 27 October 2026, and the next one isn't due until 10 November 2027. The snapshot still had the old date. That isn't rare: in the 1 November 2026 snapshot, 131,175 live companies had a confirmation statement due date between 1 and 14 November 2026, and the file can't tell you which of them have filed since.
So treat Chase? = True as a shortlist. Open each flagged company's Link, or, for a long list, check the flagged numbers with the free Companies House REST API (register for a key on the Companies House developer hub; it uses HTTP basic auth with the key as the username, and allows 600 requests per 5 minutes). Only the live register tells you today's position.
Step 4: mail-merge the ones that need chasing
Open clients_matched.csv in Excel, filter Chase? to TRUE, remove anything the live check cleared, and use it as the data source for a Word or Outlook mail merge with CS next due and Accounts next due as merge fields. Keep the script producing the sheet, not sending anything: you check the list before a client hears from you.
What else to watch for
- Some headers start with a space. 14 of the 55 column names do (
" CompanyNumber"," ConfStmtLastMadeUpDate"...). Strip them, orusecolsand lookups fail on names that look right. - Company numbers are text. 5,125,251 numbers are all digits, but 278,451 start with SC, 89,198 with NI and 46,412 with OC, among other prefixes. Read with
dtype=str, or leading zeros and prefixes get mangled. - Dates are day/month/year. Parse with
format="%d/%m/%Y", or 02/07/2027 can be read as 7 February. - The snapshot lists live companies only. A client that was dissolved simply won't match; that's another reason to read every blank row.
- The snapshot has no officer or shareholder data. This post only uses company-level fields.
The hosted version (Disclosure: we built this)
We built an Apify Actor, UK Companies House Company Search & SIC Codes, that reads the same free monthly snapshot. In lookup mode you give it up to 1,000 company names (matched exactly, or after normalising case, punctuation, & / and, Limited / Ltd and PLC forms) or company numbers, and each company row has confirmationStatementNextDue, accountsNextDue, the overdue flags, matchConfidence, dataAsOf (the snapshot date) and a sourceUrl to the company's page on the Companies House service. A name with no match comes back as a free not_found row with up to 3 similar names. It needs no API key, and exports CSV, JSON or Excel. For example (not run for this post):
{ "mode": "lookup", "companyNames": ["Tesco PLC", "BP p.l.c.", "Barclays PLC"], "maxResults": 10 } It has the same limit as the script above: it reads the monthly snapshot, and its overdue flags are worked out against the snapshot date, so a client who filed after the snapshot can still show as due. For reminders, confirm on the live register first, as in Step 3.
It's pay per event: $0.003 per company row (base price; higher Apify plans pay less), plus Apify's $0.00005 Actor start per run (per GB of memory; the default 512 MB run counts once). Unmatched names, not-found rows and duplicates are free, and maxResults caps the charged rows (the default is 25). If it matched the same eight companies as the script, that would be 8 × $0.003 + $0.00005 = $0.02405. The script above is all you need if you'd rather run it yourself.
UK Companies House Company Search on Apify
Data: Companies House, Free Company Data Product (1 November 2026 snapshot) and the Companies House service. This post and the Actor are not affiliated with or endorsed by Companies House.
Your Evenings



