Companies House publishes a free snapshot of every live UK company once a month: name, number, status, incorporation date, up to four SIC codes, registered office post town and postcode, and the accounts and confirmation statement due dates. If you sell to new businesses, or just want to know who started up in your sector and area last month, that file is all you need.
This post does it twice: first with pandas on the raw file, then with a hosted API that keeps the monthly comparison for you. At the end there's a due-date check for a list of client companies, the free API way and the hosted way.
Disclosure: the hosted API in the second half is ours, and it's paid per row. The free route comes first and works without it.
The free route: the Basic Company Data file
Download it from the Free Company Data Product page. It's split into 7 zipped CSV files of about 70 MB each (the 1 October 2026 snapshot has 5,704,711 live companies). No key and no sign-up.
Companies that are new, in IT (SIC 62xxx) and registered in the Manchester postcode area (M):
import pandas as pd
cols = ["CompanyName", "CompanyNumber", "CompanyStatus", "IncorporationDate",
"RegAddress.PostTown", "RegAddress.PostCode", "SICCode.SicText_1"]
df = pd.read_csv("BasicCompanyData-2026-10-01-part1_7.csv", dtype=str,
usecols=lambda c: c.strip() in cols)
df.columns = df.columns.str.strip() # some headers have a leading space
df["inc"] = pd.to_datetime(df["IncorporationDate"], format="%d/%m/%Y", errors="coerce")
new_it_mcr = df[
df["SICCode.SicText_1"].str.startswith("62", na=False)
& df["RegAddress.PostCode"].str.match(r"^M\d", na=False) # M1..M99, not ME/ML/MK
& (df["inc"] >= "2026-09-01")
]
print(len(new_it_mcr))
On part 1 of 7 that took about 4 seconds and found 20 companies. Loop over the 7 files for the whole register.
Two things to know:
-
SIC codes are in four columns (
SICCode.SicText_1to_4). The snippet only checks the first. A company that lists "62012" second is missed unless you check all four. -
"New" by incorporation date isn't the same as "new this month". If you want what changed between two snapshots (new companies, companies that left the register, status changes such as liquidation, accounts that just became overdue), keep last month's file and diff on
CompanyNumber. That's where it gets fiddly: 5.7 million rows a month, and a change in any of a dozen fields.
The API route: one call for a month-over-month change feed
UK Companies House on Apify loads each monthly snapshot, compares it with the one before, and returns typed change rows. The same query as above, as changes:
curl -s -X POST \
"https://api.apify.com/v2/acts/yourevenings~uk-companies-house/run-sync-get-dataset-items?maxTotalChargeUsd=0.05" \
-H "Authorization: Bearer $APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"mode": "changes",
"sicCodes": ["62"],
"postcodes": ["M"],
"changeTypes": ["new_on_register"],
"maxResults": 5,
"countAllMatches": true
}' | jq '.[] | {changeType, incorporatedOn, postTown: .registeredOffice.postTown, sicCodes, confirmationStatementNextDue}'
sicCodes is a prefix match across all four SIC codes (62 = all IT, 62012 = software development only). postcodes takes an area (M), a district (M1) or a full postcode.
When I ran this on 31 October 2026, against the 1 October snapshot, 198 companies matched, and maxResults capped the output at 5. One row, trimmed:
{
"changeType": "new_on_register",
"incorporatedOn": "2026-09-16",
"postTown": "MANCHESTER",
"sicCodes": ["58210", "58290", "62011", "62020"],
"confirmationStatementNextDue": "2027-09-29"
}
Full rows also have the company name and number, status, type, accounts due dates and overdue flags, charges, previous names, dataAsOf and a sourceUrl to the company's page on the Companies House service.
That run charged 5 change rows and one start: $0.02505. Prices are $0.005 per change row, $0.003 per company row and $0.00005 per run start (base prices; higher Apify plans pay less). maxTotalChargeUsd caps the run, and countAllMatches gives you the full match count in the run summary at no extra charge.
Other change types work the same way: removed_from_register, status_changed, name_changed, sic_changed, registered_office_moved, accounts_now_overdue, accounts_filed, confirmation_statement_now_overdue, confirmation_statement_filed, new_charge and a few more. For scale, the October snapshot had 69,566 companies new on the register and 62,734 whose accounts had just become overdue, across the whole UK. So filter by SIC, postcode or status unless you really want all of them.
Watching your own list
If you already have a list of customers or suppliers, give their numbers and turn on monitorMode:
{
"companyNumbers": ["00445790", "07524813"],
"monitorMode": true,
"stateStoreName": "my-suppliers"
}
The first run saves a free baseline. Schedule it monthly, and later runs return one row per change since then: proposal to strike off, liquidation, accounts now overdue, a new charge, a moved registered office. A run before the next snapshot is out returns a free no_new_snapshot row.
Check confirmation-statement and accounts due dates for a client list
A different job with the same data: you look after a list of client companies and want to know which ones have a confirmation statement or accounts due in the next 30 days, or already late. Two ways to do it.
Free: the Companies House REST API, one call per company
For reminders this is the one to use, because it reads the live register, so a client who filed yesterday won't show as due.
Register on the Companies House developer hub (developer.company-information.service.gov.uk), create an application and add a REST API key. It's free. Auth is HTTP basic with the key as the username and a blank password. The limit is 600 requests per 5 minutes per application; go over it and you get 429 for the rest of that window.
Each GET /company/{company_number} returns the dates you need:
-
confirmation_statement.next_dueandconfirmation_statement.overdue -
accounts.next_accounts.due_onandaccounts.next_accounts.overdue(accounts.next_duestill comes back too, but it's marked deprecated)
With your client numbers in a clients.csv with a company_number column:
import csv, os, time, datetime as dt, requests
KEY = os.environ["CH_API_KEY"] # keep the key out of the script
API = "https://api.company-information.service.gov.uk/company/"
today = dt.date.today()
soon = today + dt.timedelta(days=30)
def profile(num):
num = num.strip().upper()
if num.isdigit():
num = num.zfill(8) # 1234567 -> 01234567; SC/NI numbers unchanged
while True:
r = requests.get(API + num, auth=(KEY, ""), timeout=30)
if r.status_code == 429: # over the limit: wait for the window to reset
time.sleep(60)
continue
time.sleep(0.55) # stays under 600 requests per 5 minutes
return num, (r.json() if r.ok else None)
def day(s):
return dt.date.fromisoformat(s) if s else None
with open("clients.csv", newline="") as f, open("due.csv", "w", newline="") as out:
w = csv.writer(out)
w.writerow(["company_number", "cs_next_due", "accounts_next_due", "chase"])
for row in csv.DictReader(f):
num, p = profile(row["company_number"])
if p is None:
w.writerow([num, "", "", "check the number"])
continue
cs = day(p.get("confirmation_statement", {}).get("next_due"))
acc = day(p.get("accounts", {}).get("next_accounts", {}).get("due_on"))
late_or_soon = [d for d in (cs, acc) if d and d <= soon]
w.writerow([num, cs or "", acc or "", "yes" if late_or_soon else ""])
At that pace 500 clients take about 5 minutes. chase is "yes" for anything due within 30 days or already past due. A "check the number" row is a number the API didn't return, usually a typo in the sheet. If every row says that, check your API key first. Have the script fill in the sheet rather than send anything, and do your reminder emails from the result once you've looked at it.
Hosted: a monthly sweep with the same Actor
If you'd rather not hold a Companies House key, the uk-companies-house Actor from earlier (ours) does a sweep of your list from the monthly snapshot. Use lookup mode with the numbers:
curl -s -X POST \
"https://api.apify.com/v2/acts/yourevenings~uk-companies-house/run-sync-get-dataset-items?maxTotalChargeUsd=1.60" \
-H "Authorization: Bearer $APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"mode": "lookup",
"companyNumbers": ["00445790", "185647", "07524813"],
"maxResults": 500
}' > sweep.json
Set maxResults to the size of your list: it's the maximum number of charged rows, and the default is 25. Short numbers are zero-padded, so 185647 is read as 00185647, and SC, NI and OC numbers work as they are. A number listed twice comes back once.
Each company row has the four fields you want, plus dataAsOf (the snapshot date) and a sourceUrl to the company's page on the Companies House service. Trimmed, from a lookup run on 30 October 2026 against the 1 October snapshot:
{
"recordType": "company",
"companyNumber": "00445790",
"accountsNextDue": "2027-08-26",
"accountsOverdue": false,
"confirmationStatementNextDue": "2027-07-02",
"confirmationStatementOverdue": false,
"lastConfirmationStatementMadeUpTo": "2026-06-18",
"dataAsOf": "2026-10-01",
"sourceUrl": "https://find-and-update.company-information.service.gov.uk/company/00445790"
}
The dataset also holds free status rows (not_found, not_on_live_register, invalid_number, duplicate), so filter on recordType. Due within 30 days or already past due, judged against today:
jq --arg soon "$(date -d "+30 days" +%F)" '.[] | select(.recordType == "company")
| select([.confirmationStatementNextDue, .accountsNextDue]
| map(select(. != null and . <= $soon)) | length > 0)
| {companyNumber, confirmationStatementNextDue, accountsNextDue, dataAsOf, sourceUrl}' sweep.json
$soon is today plus 30 days (on macOS use date -v+30d +%F). Don't filter on accountsOverdue or confirmationStatementOverdue for this: both are worked out against the snapshot date, not today, so a date that fell due since the snapshot still reads false.
The catch for reminders is the monthly snapshot. It's compiled to the end of the previous month, so a client who filed after that still shows the old due date. Treat the sweep as a shortlist: before you chase anyone, open their sourceUrl, or run just the flagged numbers through the free script above.
Cost at base prices is $0.003 per company row plus $0.00005 per run start, so a 500-client list is about $1.50 a run. Not-found, invalid and duplicate rows are free. maxTotalChargeUsd caps the run.
If you run it monthly anyway, monitorMode (see "Watching your own list" above) gives you only what changed: confirmation_statement_now_overdue, confirmation_statement_filed, accounts_now_overdue and accounts_filed are among the change types.
Honest limits
-
Monthly, not live. The snapshot is compiled to the end of the previous month and published within 5 working days of month end. Overdue flags are worked out against the snapshot date. For today's position, follow the row's
sourceUrl, or use the free Companies House REST API for a handful of companies. - Company-level records only (people records aren't included). Registered office street lines are dropped (some very small companies use a home address). Post town, postcode and country are kept.
-
Live companies only. A company that dissolved shows up once as
removed_from_register, then it's gone. -
The last 3 months are kept (
dataMonthpicks an older one). - Not affiliated with or endorsed by Companies House.
If you only need this once, the pandas route is fine. If you want the monthly diff without keeping 7 files a month around, that's the job the API does.
Top comments (0)