History of uk_psc
The changes to the source code of this entry in the repository, newest first, each with the differences it made to the entry's files. They are also listed on GitLab.
[entries] Add the date when each entry was added, and remove icon_hash beff7c9
The dates are those of the commits that first added the entries' files (2020-08-11 for the entries of the initial commit); renamed entries keep the date of their first name. Changed: none
diff --git a/entries/uk_psc/__init__.py b/entries/uk_psc/__init__.py
index 7e4e39a..4ff4a35 100644
--- a/entries/uk_psc/__init__.py
+++ b/entries/uk_psc/__init__.py
@@ -48,7 +48,7 @@ citation = [('Companies House, "People with significant control (PSC) snapshot."
'https://download.companieshouse.gov.uk/en_pscdata.html'),
('Companies House, "Free Company Data Product." (2026-10-01)',
'https://download.companieshouse.gov.uk/en_output.html')]
-icon_hash = None
+date_added = '2026-10-31'
upstream_license = ("Open Government Licence v3.0", "https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/")
# Both files are dated, and only the current one is offered (the PSC
[entries] Prefix the properties in the descriptions with (Node), (Edge) or (Graph) bc3dbbd
The lists of properties at the end of the descriptions marked edge properties with "Edge" but left node properties unmarked, or grouped them in "Nodes: `a`, ...; `b`, ..." items, so the kind of a property was often unclear. Every item now starts with (Node), (Edge) or (Graph), followed by the property names, a colon and the explanation, one item per property or group of properties; qualifications (kinds of nodes, networks) follow the names in parentheses. Properties that were only named got a short explanation, and a few items that named the wrong kind of property were corrected.
diff --git a/entries/uk_psc/__init__.py b/entries/uk_psc/__init__.py
index 625f63d..7e4e39a 100644
--- a/entries/uk_psc/__init__.py
+++ b/entries/uk_psc/__init__.py
@@ -29,18 +29,18 @@ Each directed edge goes from a controller to the company that it controls, with
The network has the following properties:
-- `kind`: `company` (a UK company or overseas entity of the register), `individual`, `corporate_entity` (a legal entity that is not a UK company), `legal_person`, `super_secure`.
-- `company_number` (companies): the company number at Companies House (its page is `https://find-and-update.company-information.service.gov.uk/company/<company_number>`).
-- `name`: the name of the company, of the individual (as given in the first record), or of the legal entity.
-- `birth_month`, `birth_year` (individuals): the month and year of birth (-1 if not given).
-- `nationality`, `country_of_residence` (individuals): as given in the first record.
-- `registration_number`, `legal_form`, `legal_authority`, `country_registered` (corporate entities and legal persons): their identification, as given.
-- `company_category`, `company_status`, `country_of_origin`, `incorporation_date`, `dissolution_date`, `postcode`, `sic_codes` (companies in the company data): the category (e.g. `Private Limited Company`), the status (e.g. `Active`), the country of origin, the dates of incorporation and dissolution (`YYYY-MM-DD`), the postcode of the registered office, and the SIC codes of the activities (e.g. `99999 - Dormant Company`).
-- Edge `psc_kind`: the kind of record (e.g. `individual-person-with-significant-control`, `corporate-entity-person-with-significant-control`, `individual-beneficial-owner` for the beneficial owners of overseas entities).
-- Edge `natures_of_control`: the natures of the control (e.g. `ownership-of-shares-75-to-100-percent`, `voting-rights-25-to-50-percent`, `right-to-appoint-and-remove-directors`, `significant-influence-or-control`).
-- Edge `notified_on`, `ceased_on`: the dates on which the control was notified and ceased (`YYYY-MM-DD`; empty if it has not ceased).
-- Edge `ceased`: whether the control has ceased.
-- Edge `is_sanctioned`: for beneficial owners of overseas entities, whether they are under sanctions (1, 0, or -1 if not given)."""
+- (Node) `kind`: `company` (a UK company or overseas entity of the register), `individual`, `corporate_entity` (a legal entity that is not a UK company), `legal_person`, `super_secure`.
+- (Node) `company_number` (companies): the company number at Companies House (its page is `https://find-and-update.company-information.service.gov.uk/company/<company_number>`).
+- (Node) `name`: the name of the company, of the individual (as given in the first record), or of the legal entity.
+- (Node) `birth_month`, `birth_year` (individuals): the month and year of birth (-1 if not given).
+- (Node) `nationality`, `country_of_residence` (individuals): as given in the first record.
+- (Node) `registration_number`, `legal_form`, `legal_authority`, `country_registered` (corporate entities and legal persons): their identification, as given.
+- (Node) `company_category`, `company_status`, `country_of_origin`, `incorporation_date`, `dissolution_date`, `postcode`, `sic_codes` (companies in the company data): the category (e.g. `Private Limited Company`), the status (e.g. `Active`), the country of origin, the dates of incorporation and dissolution (`YYYY-MM-DD`), the postcode of the registered office, and the SIC codes of the activities (e.g. `99999 - Dormant Company`).
+- (Edge) `psc_kind`: the kind of record (e.g. `individual-person-with-significant-control`, `corporate-entity-person-with-significant-control`, `individual-beneficial-owner` for the beneficial owners of overseas entities).
+- (Edge) `natures_of_control`: the natures of the control (e.g. `ownership-of-shares-75-to-100-percent`, `voting-rights-25-to-50-percent`, `right-to-appoint-and-remove-directors`, `significant-influence-or-control`).
+- (Edge) `notified_on`, `ceased_on`: the dates on which the control was notified and ceased (`YYYY-MM-DD`; empty if it has not ceased).
+- (Edge) `ceased`: whether the control has ceased.
+- (Edge) `is_sanctioned`: for beneficial owners of overseas entities, whether they are under sanctions (1, 0, or -1 if not given)."""
tags = ['Economic', 'Commerce', 'Social', 'Multigraph', 'Unweighted', 'Metadata']
url = ['https://download.companieshouse.gov.uk/en_pscdata.html',
'https://download.companieshouse.gov.uk/en_output.html']
[entries] New entry: uk_psc 109b820
diff --git a/entries/uk_psc/__init__.py b/entries/uk_psc/__init__.py
new file mode 100644
index 0000000..625f63d
--- /dev/null
+++ b/entries/uk_psc/__init__.py
@@ -0,0 +1,235 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2026 Tiago de Paula Peixoto <tiago@skewed.de>
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU Affero General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+from .. import *
+import json
+import pyarrow
+import pyarrow.csv
+
+title = "UK persons with significant control of companies (Companies House, 2026)"
+description = r"""The network of the persons with significant control (PSC) of the companies of the United Kingdom, from the PSC register of Companies House, the UK registrar of companies. Since 2016, UK companies and limited liability partnerships must report the individuals and legal entities that have significant control over them (more than 25 % of the shares or votes, the right to appoint or remove the majority of the board, or significant influence or control otherwise), and since 2022 the overseas entities that own UK property must report their registrable beneficial owners. The network is built from the daily snapshot of the register of 31 October 2026 (the snapshot is replaced every day), and contains both the current controllers and those that have ceased (with their date of cessation), 15,174,793 records in all.
+
+Each directed edge goes from a controller to the company that it controls, with the nature of the control. The companies are identified by their company number, and described by the "Free Company Data Product" of Companies House of 1 October 2026 (name, category, status, dates, postcode, SIC codes); the companies that are not in it (e.g. dissolved long ago) only have their number. The controllers that are legal entities registered in the UK (corporate PSCs whose registration number is that of a UK company) are the same nodes as the companies themselves, so that the network contains chains of control between UK companies; the other corporate controllers are identified by their name, registration number and country of registration. The register gives no identifier of the individuals: they are merged by their name (forenames, middle names and surname, without titles, upper-cased) and month and year of birth (the day of birth is not public), so that two persons with the same name born in the same month are taken as one, and the same person with differently written names (or a wrong date) is split into several nodes. Legal persons (e.g. governments, which have no registration number) are merged by name, legal form and legal authority. The "super-secure" PSCs, whose details are withheld for their protection, are one node each (without any data). The statements of companies that have no registrable PSC, and the exemptions, are not included.
+
+The network has the following properties:
+
+- `kind`: `company` (a UK company or overseas entity of the register), `individual`, `corporate_entity` (a legal entity that is not a UK company), `legal_person`, `super_secure`.
+- `company_number` (companies): the company number at Companies House (its page is `https://find-and-update.company-information.service.gov.uk/company/<company_number>`).
+- `name`: the name of the company, of the individual (as given in the first record), or of the legal entity.
+- `birth_month`, `birth_year` (individuals): the month and year of birth (-1 if not given).
+- `nationality`, `country_of_residence` (individuals): as given in the first record.
+- `registration_number`, `legal_form`, `legal_authority`, `country_registered` (corporate entities and legal persons): their identification, as given.
+- `company_category`, `company_status`, `country_of_origin`, `incorporation_date`, `dissolution_date`, `postcode`, `sic_codes` (companies in the company data): the category (e.g. `Private Limited Company`), the status (e.g. `Active`), the country of origin, the dates of incorporation and dissolution (`YYYY-MM-DD`), the postcode of the registered office, and the SIC codes of the activities (e.g. `99999 - Dormant Company`).
+- Edge `psc_kind`: the kind of record (e.g. `individual-person-with-significant-control`, `corporate-entity-person-with-significant-control`, `individual-beneficial-owner` for the beneficial owners of overseas entities).
+- Edge `natures_of_control`: the natures of the control (e.g. `ownership-of-shares-75-to-100-percent`, `voting-rights-25-to-50-percent`, `right-to-appoint-and-remove-directors`, `significant-influence-or-control`).
+- Edge `notified_on`, `ceased_on`: the dates on which the control was notified and ceased (`YYYY-MM-DD`; empty if it has not ceased).
+- Edge `ceased`: whether the control has ceased.
+- Edge `is_sanctioned`: for beneficial owners of overseas entities, whether they are under sanctions (1, 0, or -1 if not given)."""
+tags = ['Economic', 'Commerce', 'Social', 'Multigraph', 'Unweighted', 'Metadata']
+url = ['https://download.companieshouse.gov.uk/en_pscdata.html',
+ 'https://download.companieshouse.gov.uk/en_output.html']
+citation = [('Companies House, "People with significant control (PSC) snapshot." (2026-10-31)',
+ 'https://download.companieshouse.gov.uk/en_pscdata.html'),
+ ('Companies House, "Free Company Data Product." (2026-10-01)',
+ 'https://download.companieshouse.gov.uk/en_output.html')]
+icon_hash = None
+upstream_license = ("Open Government Licence v3.0", "https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/")
+
+# Both files are dated, and only the current one is offered (the PSC
+# snapshot is replaced daily, the company data monthly): the server needs
+# the cached copies
+upstream_prefix = 'https://download.companieshouse.gov.uk'
+_psc = "persons-with-significant-control-snapshot-2026-10-31.zip:persons-with-significant-control-snapshot-2026-10-31.txt"
+_companies = "BasicCompanyDataAsOneFile-2026-10-01.zip:BasicCompanyDataAsOneFile-2026-10-01.csv"
+files = [((_psc, _companies), None, None)]
+
+def fetch_upstream(force=False):
+ return fetch_upstream_files(__name__.split(".")[-1], upstream_prefix, files,
+ force)
+
+# For checks only: if set, only this many records are read
+_check_rows = None
+
+_uk = {"england & wales", "england and wales", "england", "wales", "scotland",
+ "northern ireland", "united kingdom", "uk", "great britain",
+ "england & wales companies house", "companies house"}
+
+def _norm(x):
+ return " ".join(str(x or "").split())
+
+def _regnum(x):
+ x = _norm(x).upper().replace(" ", "")
+ if x.isdigit() and len(x) <= 8:
+ x = x.zfill(8)
+ return x
+
+def _date(x):
+ "dd/mm/yyyy as yyyy-mm-dd"
+ d = (x or "").strip().split("/")
+ return f"{d[2]}-{d[1]}-{d[0]}" if len(d) == 3 else ""
+
+_vprops = ["kind", "company_number", "name", "nationality",
+ "country_of_residence", "registration_number", "legal_form",
+ "legal_authority", "country_registered", "company_category",
+ "company_status", "country_of_origin", "incorporation_date",
+ "dissolution_date", "postcode"]
+_etypes = {"psc_kind": "string", "natures_of_control": "vector<string>",
+ "notified_on": "string", "ceased_on": "string", "ceased": "bool",
+ "is_sanctioned": "int16_t"}
+
+@cache_network()
+@coerce_props()
+@annotate()
+def parse(alts=None):
+ import polars as pl
+ name = __name__.split(".")[-1]
+ for fnames, alt, fmt in files:
+ if alts is not None and alt not in alts:
+ continue
+ # the company data (only the needed columns, streamed; the names
+ # of some columns start with a space)
+ sic = [f"SICCode.SicText_{i}" for i in range(1, 5)]
+ need = ["CompanyNumber", "CompanyName", "CompanyCategory",
+ "CompanyStatus", "CountryOfOrigin", "IncorporationDate",
+ "DissolutionDate", "RegAddress.PostCode"] + sic
+ with open_upstream_file(name, fnames[1], "rb") as f:
+ header = f.readline().decode("utf-8-sig").rstrip("\r\n").split(",")
+ cols = [c for c in header if c.strip() in need]
+ reader = pyarrow.csv.open_csv(
+ f, read_options=pyarrow.csv.ReadOptions(column_names=header,
+ block_size=1 << 26),
+ convert_options=pyarrow.csv.ConvertOptions(
+ include_columns=cols,
+ column_types={c: pyarrow.string() for c in cols}))
+ comp = pl.from_arrow(pyarrow.Table.from_batches(list(reader)))
+ comp = comp.rename({c: c.strip() for c in comp.columns})
+ comp = comp.select(*need[:8],
+ pl.concat_list(sic).list.eval(
+ pl.element().filter(pl.element().str.len_chars() > 0))
+ .alias("sic"))
+ cidx = {c: i for i, c in enumerate(comp["CompanyNumber"].to_list())}
+
+ idx = {}
+ attrs = []
+ cnode = {} # company row (or -1) for the nodes of companies
+
+ def node(key, a):
+ v = idx.get(key)
+ if v is None:
+ v = idx[key] = len(attrs)
+ attrs.append(a)
+ return v
+
+ def company(num):
+ v = node(("C", num), dict(kind="company", company_number=num))
+ if v not in cnode:
+ cnode[v] = cidx.get(num, -1)
+ return v
+
+ edges = []
+ ep = defaultdict(list)
+ with open_upstream_file(name, fnames[0], "r") as f:
+ for n, line in enumerate(f):
+ if _check_rows is not None and n >= _check_rows:
+ break
+ o = json.loads(line)
+ d = o.get("data", {})
+ k = d.get("kind", "")
+ if "company_number" not in o or not (
+ "person-with-significant-control" in k or
+ "beneficial-owner" in k):
+ continue
+ t = company(o["company_number"])
+ ident = d.get("identification") or {}
+ pname = _norm(d.get("name"))
+ if k.startswith("individual"):
+ ne = d.get("name_elements") or {}
+ full = " ".join(_norm(ne.get(x)) for x in
+ ["forename", "middle_name", "surname"]
+ if ne.get(x)) or pname
+ dob = d.get("date_of_birth") or {}
+ m, y = dob.get("month", -1), dob.get("year", -1)
+ s = node(("I", full.upper(), m, y),
+ dict(kind="individual", name=pname or full,
+ birth_month=m, birth_year=y,
+ nationality=_norm(d.get("nationality")),
+ country_of_residence=_norm(d.get("country_of_residence"))))
+ elif k.startswith("corporate-entity"):
+ reg = _regnum(ident.get("registration_number"))
+ country = _norm(ident.get("country_registered") or
+ ident.get("place_registered"))
+ if reg and (reg in cidx or (country.lower() in _uk and
+ reg[:2].isalnum())):
+ s = company(reg)
+ else:
+ s = node(("E", pname.upper(), reg, country.upper()),
+ dict(kind="corporate_entity", name=pname,
+ registration_number=_norm(ident.get("registration_number")),
+ legal_form=_norm(ident.get("legal_form")),
+ legal_authority=_norm(ident.get("legal_authority")),
+ country_registered=country))
+ elif k.startswith("legal-person"):
+ lf = _norm(ident.get("legal_form"))
+ la = _norm(ident.get("legal_authority"))
+ s = node(("L", pname.upper(), lf.upper(), la.upper()),
+ dict(kind="legal_person", name=pname,
+ legal_form=lf, legal_authority=la))
+ else:
+ # super-secure: no data, one node per record
+ s = node(("S", n), dict(kind="super_secure"))
+ edges.append((s, t))
+ ep["psc_kind"].append(k)
+ ep["natures_of_control"].append(d.get("natures_of_control") or [])
+ ep["notified_on"].append(d.get("notified_on") or "")
+ ep["ceased_on"].append(d.get("ceased_on") or "")
+ ep["ceased"].append(bool(d.get("ceased_on") or d.get("ceased")))
+ sanc = d.get("is_sanctioned")
+ ep["is_sanctioned"].append(-1 if sanc is None else int(bool(sanc)))
+ del idx
+ # the data of the companies
+ rows = numpy.array([cnode.get(v, -1) for v in range(len(attrs))])
+ sel = comp[rows[rows >= 0].tolist()]
+ cols = {"name": "CompanyName", "company_category": "CompanyCategory",
+ "company_status": "CompanyStatus",
+ "country_of_origin": "CountryOfOrigin",
+ "incorporation_date": "IncorporationDate",
+ "dissolution_date": "DissolutionDate",
+ "postcode": "RegAddress.PostCode"}
+ for vi, r in zip(numpy.nonzero(rows >= 0)[0], sel.iter_rows(named=True)):
+ a = attrs[vi]
+ for k, c in cols.items():
+ x = _norm(r[c])
+ a[k] = _date(x) if k.endswith("_date") else x
+ a["sic_codes"] = r["sic"]
+ del comp, sel, cidx
+
+ g = Graph(directed=True)
+ g.add_vertex(len(attrs))
+ g.add_edge_list(edges)
+ del edges
+ for k in _vprops:
+ g.vp[k] = g.new_vp("string", vals=[a.get(k, "") for a in attrs])
+ for k in ["birth_month", "birth_year"]:
+ g.vp[k] = g.new_vp("int16_t", vals=[a.get(k, -1) for a in attrs])
+ g.vp.sic_codes = g.new_vp("vector<string>", vals=[a.get("sic_codes", [])
+ for a in attrs])
+ del attrs
+ for k, t in _etypes.items():
+ g.ep[k] = g.new_ep(t, vals=ep[k])
+ yield alt, g