Home / Developers
Developers

How to parse Companies House iXBRL accounts in Python

Updated By
Short answerTo parse Companies House iXBRL accounts, read every ix:nonFraction tag, resolve its contextRef to a date and check it has no dimensions, keep only facts dated at the balance sheet date, then apply the format, scale and sign attributes. With first-match parsing, net assets agreed with equity in only 66.6% of our filings.

What an iXBRL file contains

An iXBRL accounts file is an XHTML page that a browser shows as normal accounts, with the figures wrapped in tags that a program can read. Each number sits in an ix:nonFraction element with a taxonomy name such as core:NetAssetsLiabilities and a contextRef that points to an xbrli:context describing the period and, optionally, a dimension. You get these files from the free Companies House bulk accounts data or one at a time from the Document API.

A real fact from TESCONS LTD (10623966), accounts to 31 May 2025:

<ix:nonFraction name="core:NetAssetsLiabilities" contextRef="CurrYearEnd"
    unitRef="GBP" decimals="0">183</ix:nonFraction>

And the context it points to:

<xbrli:context id="CurrYearEnd">
  <xbrli:entity>
    <xbrli:identifier scheme="http://www.companieshouse.gov.uk/">10623966</xbrli:identifier>
  </xbrli:entity>
  <xbrli:period><xbrli:instant>2025-05-31</xbrli:instant></xbrli:period>
</xbrli:context>

Balance sheet items use an instant; profit and loss items and employee numbers use a startDate and endDate. The context ID itself means nothing: in the same file, the context called CurrYearStart is dated 2024-05-31, which is the comparative balance sheet.

Why the first matching tag is the wrong answer

A single filing reports the same concept several times: for this year, for last year, and split by dimensions such as share capital versus retained earnings, or creditors due within versus after one year. A parser that takes the first Equity tag it meets reads one of the breakdowns.

from lxml import etree

root = etree.parse("Prod224_2602_10623966_20250531.html").getroot()
first = {}
for el in root.iter("{*}nonFraction"):
    name = el.get("name").split(":")[-1]
    first.setdefault(name, (el.text, el.get("contextRef")))
print(first["Equity"], first["NetAssetsLiabilities"])
# ('100', 'CurrYearEnd_Dim007') ('183', 'CurrYearEnd')

The first Equity fact is 100, in a context with the dimension EquityClassesDimension = ShareCapital: it is the called-up share capital line, not total equity. Total equity is 183 and matches net assets, as it must. The file contains six Equity facts in all.

We learned this on our own data. Our first parser took the first occurrence and, for this filing, reported equity of 100 and a period end of 31 May 2024. After we switched to resolving contexts, net assets equal equity in 99.4% of filings, against 66.6% before. Across about 37 million filings, that difference is millions of wrong numbers.

A minimal parser that resolves contexts

The parser below keeps a fact only when its context has the balance sheet date and no dimensions, with one deliberate exception for creditors split by maturity. It uses lxml. We ran it on 17 October 2026 against 2,000 files from the Companies House daily bulk data: no errors, and net assets equalled equity in 99.4% of the files that reported both.

import re
import sys
from pathlib import Path

from lxml import etree

# Local tag name -> output field. FRS 102 / FRS 105 names first, old UK GAAP after.
TAGS = {
    "NetAssetsLiabilities": "net_assets",
    "Equity": "equity",
    "ShareholderFunds": "equity",
    "CurrentAssets": "current_assets",
    "CashBankOnHand": "cash",
    "CashBankInHand": "cash",
    "Debtors": "debtors",
    "Creditors": "creditors",
    "CreditorsDueWithinOneYear": "creditors_within_one_year",
    "CreditorsDueAfterOneYear": "creditors_after_one_year",
    "NetCurrentAssetsLiabilities": "net_current_assets",
    "TotalAssetsLessCurrentLiabilities": "total_assets_less_current_liabilities",
    "PropertyPlantEquipment": "tangible_assets",
    "TangibleFixedAssets": "tangible_assets",
    "TurnoverRevenue": "turnover",
    "ProfitLoss": "profit_loss",
    "AverageNumberEmployeesDuringPeriod": "employees",
}


def local(el):
    return etree.QName(el).localname


def load(source):
    # source: a path or a file-like object. recover=True tolerates the
    # occasional malformed file in the bulk archives.
    return etree.parse(source, etree.XMLParser(huge_tree=True, recover=True)).getroot()


def parse_contexts(root):
    """context id -> (date, members). Date is the instant or the period end date;
    members lists the dimension members, empty for a plain (headline) context."""
    out = {}
    for ctx in root.iter("{*}context"):
        date, members = "", []
        for child in ctx.iter(etree.Element):  # skips comments
            name = local(child)
            if name in ("instant", "endDate"):
                date = (child.text or "").strip()
            elif name in ("explicitMember", "typedMember"):
                members.append("".join(child.itertext()).strip().split(":")[-1])
        out[ctx.get("id")] = (date, members)
    return out


def to_number(el):
    text = "".join(el.itertext()).strip()
    fmt = (el.get("format") or "").lower()
    if "zerodash" in fmt or "numdash" in fmt or "fixed-zero" in fmt or not any(c.isdigit() for c in text):
        value = 0.0
    else:
        s = re.sub(r"[^\d.,]", "", text)
        if "comma-decimal" in fmt or "numcommadecimal" in fmt or "numdotcomma" in fmt:
            s = s.replace(".", "").replace(",", ".")
        else:
            s = s.replace(",", "")
        if not re.fullmatch(r"\d+(\.\d+)?", s):
            return None
        value = float(s)
    scale = int(el.get("scale") or 0)
    if el.get("name", "").endswith("AverageNumberEmployeesDuringPeriod"):
        scale = max(scale, 0)  # some packages tag headcount with scale="-2"
    value *= 10 ** scale
    if el.get("sign") == "-":
        value = -value
    return value


def parse_file(source, name=None):
    """Parse one iXBRL file. name is the bulk file name, e.g.
    Prod223_4238_00009604_20250930.html (defaults to the path)."""
    root = load(source)
    contexts = parse_contexts(root)

    # Balance sheet date: the date in the bulk file name, if a dimensionless
    # context ends on it; otherwise the latest dimensionless date in the file.
    dates = {d for d, members in contexts.values() if d and not members}
    m = re.search(r"_(\d{4})(\d{2})(\d{2})\.", Path(name or str(source)).name)
    name_date = f"{m[1]}-{m[2]}-{m[3]}" if m else None
    bs_date = name_date if name_date in dates else max(dates, default=None)

    result = {"balance_sheet_date": bs_date}
    for el in root.iter("{*}nonFraction"):
        field = TAGS.get(el.get("name", "").split(":")[-1])
        ctx = contexts.get(el.get("contextRef"))
        if not field or ctx is None:
            continue
        date, members = ctx
        if date != bs_date:
            continue  # comparative year
        if members:
            # Breakdowns (share capital, retained earnings, ...) are not headline
            # figures. One exception: Creditors split by maturity.
            if field != "creditors" or len(members) != 1:
                continue
            if "WithinOneYear" in members[0]:
                field = "creditors_within_one_year"
            elif "AfterOneYear" in members[0]:
                field = "creditors_after_one_year"
            else:
                continue
        if field in result:
            continue  # keep the first headline value
        value = to_number(el)
        if value is not None:
            result[field] = value
    return result


if __name__ == "__main__":
    for p in sys.argv[1:]:
        print(Path(p).name, parse_file(p))

Save it as parse_ixbrl.py, install the dependency with pip install lxml and run it on one or more files:

python parse_ixbrl.py Prod224_2602_10623966_20250531.html

Output for the TESCONS filing:

Prod224_2602_10623966_20250531.html {'balance_sheet_date': '2025-05-31', 'debtors': 9851.0, 'cash': 967.0, 'current_assets': 10818.0, 'creditors_within_one_year': 8766.0, 'net_current_assets': 2052.0, 'total_assets_less_current_liabilities': 2052.0, 'creditors_after_one_year': 1869.0, 'net_assets': 183.0, 'equity': 183.0, 'employees': 1.0}

Reading a whole bulk ZIP

You do not need to unzip the bulk files to disk. This script streams a daily or monthly archive to CSV:

import csv
import io
import sys
import zipfile

from parse_ixbrl import parse_file

FIELDS = ["company_number", "balance_sheet_date", "net_assets", "equity", "cash",
          "current_assets", "creditors_within_one_year", "employees"]

writer = csv.DictWriter(sys.stdout, fieldnames=FIELDS, extrasaction="ignore")
writer.writeheader()
with zipfile.ZipFile(sys.argv[1]) as z:
    for name in z.namelist():
        if not name.lower().endswith((".html", ".xhtml")):
            continue  # .xml files are plain XBRL, .zip files are packages
        row = parse_file(io.BytesIO(z.read(name)), name)
        row["company_number"] = name.split("_")[2]
        writer.writerow(row)
python run_zip.py Accounts_Bulk_Data-2026-06-10.zip > accounts.csv

That daily file holds 9,717 entries (9,706 iXBRL files and 11 ZIP packages) and took about 30 seconds on one laptop core. Use multiprocessing across files for the monthly archives, which are 1.8 to 4.1 GB each.

Contexts and periods

Resolve every fact through its context and never infer anything from the context ID. The rules that work:

  1. Build a map from context ID to its date (the instant, or the endDate for a duration) and its dimension members (xbrldi:explicitMember or typedMember inside segment or scenario).
  2. Find the balance sheet date. Bulk file names end with it (Prod224_2602_10623966_20250531.html). Confirm that a context without dimensions has that date; if not, take the latest date among contexts without dimensions.
  3. For balance sheet items, keep facts whose context instant equals that date. For profit and loss items, keep facts whose endDate equals it, and prefer the longest duration if there are two.
  4. Ignore facts with dimensions unless you need a specific breakdown, such as creditors due within one year.

Scale, sign and format attributes

The text inside the tag is not the value until you apply three attributes.

AttributeExampleWhat to do
formatixt:numcommadot, ixt:num-dot-decimalRemove the thousands separators
formatixt:numdotcomma, ixt:num-comma-decimalComma is the decimal mark: swap the separators
formatixt:numdash, ixt2:zerodash, ixt:fixed-zeroThe value is 0, whatever the text shows (often a dash)
scalescale="3"Multiply by 10 to that power: "1,250" with scale 3 is 1,250,000
signsign="-"The value is negative; the text never carries the minus

Brackets in the text are presentation, not a sign. Some filers show net current liabilities in brackets without sign="-", so check that current assets minus creditors due within one year equals net current assets, and fix the sign when it fails. Some software packages tag the average number of employees with scale="-2"; a headcount of 0.02 is a tagging error, not a fraction of a person.

Tag variants across taxonomies

Tag names changed when old UK GAAP gave way to FRS 102 and FRS 105, and filings from 2008 to around 2017 use the older names. Map both to the same field.

FieldFRS 102 / FRS 105 tagOlder UK GAAP tag
Net assetsNetAssetsLiabilitiesNetAssetsLiabilitiesIncludingPensionAssetLiability
EquityEquityShareholderFunds
CashCashBankOnHandCashBankInHand
Creditors within one yearCreditors with a WithinOneYear maturity memberCreditorsDueWithinOneYear
Creditors after one yearCreditors with an AfterOneYear maturity memberCreditorsDueAfterOneYear
Tangible assetsPropertyPlantEquipmentTangibleFixedAssets
Share capitalShareCapitalCalledUpShareCapital
TurnoverTurnoverRevenueTurnoverGrossOperatingRevenue
Profit after taxProfitLossProfitLossForPeriod
Total assets less current liabilitiesTotalAssetsLessCurrentLiabilitiesTotalAssetsLessCurrentLiabilities
EmployeesAverageNumberEmployeesDuringPeriodAverageNumberEmployeesDuringPeriod

Match on the local name (after the colon), not the prefix: the prefix varies between software packages and taxonomy years. The same goes for the ix namespace. The TESCONS filing uses http://www.xbrl.org/2008/inlineXBRL, newer filings use the 2013 namespace, and a parser with a fixed namespace map silently finds nothing in one of them. root.iter("{*}nonFraction") matches both.

Duplicates and amended accounts

The same company and balance sheet date can appear more than once. Amended accounts are filed later for a period already reported, and every filing repeats the prior year as comparatives. Keep one record per company and balance sheet date, prefer the most recently published filing, and use comparatives from a later filing as a check rather than a replacement. The daily and monthly files overlap, so deduplicate when you load both.

Validating your numbers

Accounts have identities you can test on every row, and they catch most parser bugs:

If more than a few percent of rows fail the first check, your context resolution is wrong.

When to use a hosted API instead

Parsing one daily file is a good weekend project; the full history is a long one. The archives since 2008 run to hundreds of gigabytes, and the hard cases (older taxonomies, odd formats, amendments) take most of the time. Coverage has a ceiling too: Companies House says about 75% of accounts are filed electronically, and large groups often file PDF only.

Our API serves figures from about 37 million parsed filings covering about 7 million companies, so you can skip the pipeline. A free key gives 50 requests a month: get one on the developer docs page. For the trade-offs between building and buying, see how to get Companies House financial data as JSON. Micro-entity accounts, the most common type at 32.4% of active companies, carry no profit and loss figures at all (accounts types data), so expect turnover to be missing in most rows whichever route you take.

Sources

FAQ

What Python library should I use to parse iXBRL?

lxml is enough for Companies House accounts: it parses the XHTML quickly, copes with large files and lets you match tags by local name. Full XBRL processors such as Arelle validate against the taxonomy and resolve everything formally, but they are slower across millions of files. Start with lxml and the context rules on this page, and move to a full processor only if you need taxonomy validation.

Why does my parser return last year's figures?

Because each filing contains the comparative year too, and your parser takes the first fact it finds for each tag. Comparative values are tagged just like current ones, and the context IDs can be misleading. Resolve each fact's contextRef to its date and keep only facts dated at the balance sheet date, which is also the date at the end of the bulk file name.

Are all Companies House accounts in iXBRL?

No. Companies House says about 75% of the 2.2 million accounts it expects each year are filed electronically and appear in its accounts data. Paper and PDF filings are not machine-readable. Some electronic files in the bulk data are plain XBRL (.xml) or ZIP packages rather than iXBRL, so handle or skip them explicitly.

Why are there .zip files inside the bulk accounts ZIP?

The monthly product page lists ZIP files of iXBRL as a third file type alongside .html and .xml. In the 10 June 2026 daily file there were 11 such packages out of 9,717 entries. Open them with zipfile and parse the iXBRL documents inside with the same code.

Try it with a free key. 50 requests a month, REST and MCP, no card. Company register, iXBRL financial history since 2008 and watchlist alerts.

Get a free API key