Working with Entities
The entities repository is the primary way to work with FollowTheMoney data in ftm-lakehouse. It provides a unified API for reading, writing, and querying entities.
Overview
Entities in ftm-lakehouse are stored as statements – granular property-level records. This design enables:
- Versioning: Track changes over time via
first_seen/last_seen - Provenance: Know where each piece of data came from (
origin,role,original_value, and other metadata from the Statement model) - Incremental updates: Add new data without reprocessing everything
- Simple identity: entities are keyed on
entity_id; this is a single-dataset store with no cross-source resolution, socanonical_idis not persisted (it always equalsentity_id)
The underlying storage is a single Delta Lake table per dataset, partitioned by (shard, bucket, origin) – see Sharded append-only pattern for the partition keys, write fence and merge semantics. Writes are append-only: deduplication, first_seen folding, and tombstone reaping happen via the async optimize operation.
Quick Start
from ftm_lakehouse import ensure_dataset, get_entities
ensure_dataset("my_dataset")
entities = get_entities("my_dataset")
# Write entities
with entities.writer(origin="import") as writer:
for entity in source:
writer.add_entity(entity)
# Persist the journal to parquet
entities.flush()
# Read a specific entity
entity = entities.get("entity-id-123")
# Query entities
for entity in entities.query():
process(entity)
The EntityRepository handle is resolved through an LRU-cached factory – every path addressing the same dataset (library, CLI, operations, API server) shares one instance.
Writing Entities
Single Entity
from ftm_lakehouse import ensure_dataset
from followthemoney import model
dataset = ensure_dataset("my_dataset")
entity = model.make_entity("Person")
entity.id = "jane-doe"
entity.add("name", "Jane Doe")
entity.add("nationality", "us")
entities.add(entity, origin="manual")
Bulk Writing (through the journal)
For interactive ingestion that wants the journal's crash-safety guarantees:
with entities.writer(origin="bulk_import") as writer:
for entity in source_entities:
writer.add_entity(entity)
Writes buffer in a SQL journal – an append-only table carrying the same columns as the parquet store. Call entities.flush() to drain it into parquet:
Bulk Import (bypassing the journal)
For one-shot loads where journal write-amplification is wasteful (millions of entities from an exported file), stream through an in-memory buffer and write directly to parquet:
from datetime import datetime, timezone
from ftmq.io import smart_read_proxies
from ftm_lakehouse.logic.entities.buffer import EntityBuffer
repo = get_entities("my_dataset")
buffer = EntityBuffer(repo.dataset, origin="bulk")
now = datetime.now(timezone.utc)
for proxy in smart_read_proxies("entities.ftm.json"):
buffer.add_entity(proxy)
if len(buffer) >= 1_000_000:
repo.write_batches([buffer.flush_table(now)])
if buffer:
repo.write_batches([buffer.flush_table(now)])
The EntityBuffer keys statements by (id, origin, fragment); buffer.flush_table() drains it as one packed Arrow table, and repo.write_batches appends it as one parquet file per (shard, bucket, origin) triple it spans. The buffer never sees a shard count – shard is not a packed column, and ParquetStore.append derives the stored key from entity_id – so nothing here can place a row against the wrong count.
The CLI command ftm-lakehouse entities import does exactly this.
Reading Entities
Reads assume an optimized store
Statement reads target a live WHERE deleted_at IS NULL view with no read-time dedupe. Dedupe, fragment supersession, and tombstone reaping all happen in merge (see Deduplication), so between a write and the next optimize/merge, query can surface duplicate statements and entities whose delete hasn't been applied yet. Run optimize before querying, exporting, or computing statistics.
Get by ID
Query with Filters
Filters are expressed as an ftmq Query – built from filter nodes (M for statement meta fields like origin / entity_id / schema, P for entity properties):
from ftmq.query import M, Query
for entity in entities.query(Query(M(origin="import"))):
print(entity.id)
ids = ["jane-doe", "john-smith"]
for entity in entities.query(Query(M(entity_id__in=ids))):
print(entity.caption)
# By schema – the (shard, bucket) partition prunes are derived from the
# query (schema → bucket, entity_id → shard)
for entity in entities.query(Query(M(schema="Person"))):
print(entity.schema.name)
Stream from Exported File
For full-dataset iteration, streaming from the pre-exported JSON file is typically faster than running an aggregating query against the parquet store:
stream() reads from entities.ftm.json. Use query() to read the live statement store; iterate() on the CLI does the same.
The Origin Field
origin is part of the partition key (alongside shard and bucket) and tracks where data came from. Useful for filtering, auditing, and partition-scoped re-runs:
with entities.writer(origin="source_a") as writer:
for entity in source_a_entities:
writer.add_entity(entity)
with entities.writer(origin="source_b") as writer:
for entity in source_b_entities:
writer.add_entity(entity)
for entity in entities.query(Query(M(origin="source_a"))):
print(entity.id)
The Role Field
Where origin records where data came from, role records who asserted it – an identifier a submitting application supplies for the user, service account or other actor behind a write:
with entities.writer(origin="webui", role="user:42") as writer:
writer.add_entity(entity)
# a per-statement override, for producers that mix roles in one batch
with entities.writer(origin="webui", role="user:42") as writer:
writer.add_statement(stmt, role="user:7")
role is optional – None (the default) means "no role", stored as NULL. It is not part of the statement id: identical content produces the same content-addressed id whoever asserts it.
Roles are row identity, not a last-writer-wins field
role joins origin and fragment as the store's row identity, so two roles asserting identical content survive as two rows rather than the later one overwriting the earlier:
with entities.writer(role="user:42") as writer:
writer.add_statement(stmt)
with entities.writer(role="user:7") as writer:
writer.add_statement(stmt) # same content
entities.flush()
entities.merge()
# two rows, one per role - full provenance of who asserted what
One role re-asserting the same content still collapses to one row, and that row keeps its original first_seen. A role's first assertion of content another role already wrote is a new row with its own first_seen, so diff exports (which detect change on first_seen) surface it.
Row multiplication is provenance only. The assembled entity is unchanged – its properties still hold one value per distinct value, whoever asserted it:
Because it is an ordinary storage column, filtering needs no special support – use the C (context / column) family:
from ftmq.query import C, Query
for entity in entities.query(Query(C(role="user:42"))):
print(entity.id)
Like C(origin=...), this selects entities that have a matching statement, not individual rows.
Deletes are per row
delete_entity reads the live rows and writes one matching tombstone each, so it deletes what every role asserted – deleting the entity means deleting the entity. To remove only one role's assertion, read that row back and tombstone it alone:
target = next(
s for s in entities.query_statements(Query(M(entity_id="acme")))
if s.role == "user:42"
)
entities.delete_statement(target) # the read-back statement carries its role
A tombstone that dropped the role would land in a different merge group and shadow nothing – which is why delete_statement takes a role= override for hand-built plain statements, mirroring fragment=.
Round-tripping
statements.csv carries a role column, so a statement-level export/import keeps every role exactly. entities.ftm.json aggregates an entity's rows into one payload, so its role context key is a list – and on re-import a single role is recovered while multiple roles are ambiguous and fall back to the import default, exactly as origin behaves. Use the statement export when per-row role provenance has to survive a round-trip.
On the CLI, --role supplies the default for input that carries none; a payload's own role wins. There is deliberately no --override-role: roles arrive inside submissions rather than from the command line.
Fragment Supersession
Every statement is written in one of two modes, decided by the producer per statement. The default is non-fragment: content-addressed dedup, where each statement id lives or dies on its own last_seen and distinct ids never interact – everything described in this document so far.
Passing a fragment switches a statement into supersession mode (the same capability as the original followthemoney-store fragment column): a later emission of the same (entity_id, prop, fragment) triple completely replaces the older emission for that triple, even though the changed values produce different content-addressed statement ids.
with entities.writer(origin="csv_import") as writer:
writer.add_entity(company, fragment="row42")
# later, the source row changed – re-emit under the same fragment:
with entities.writer(origin="csv_import") as writer:
writer.add_entity(updated_company, fragment="row42")
# after flush, only the updated values are visible – the first emission
# is superseded, not accumulated
The typical use is one fragment per source row in a CSV-style ingest (or per document in a crawler): re-processing the source replaces what that row previously said about the entity instead of accumulating stale values forever. add_statement accepts the same parameter for statement-level producers.
Semantics
- Scope is per
(entity_id, prop, fragment), not per fragment as a whole. If the first emission hadname,addressandcountryand the re-emission only hasnameandaddress, the oldcountryvalue survives – no newer row exists in its group. If you want whole-fragment replacement, emit explicit tombstones for the dropped props: statements read back from the store areftmq.store.lake.LakeStatements carrying their own fragment, sodelete_statement(stmt)shadows the right group; thefragment=override is only needed for hand-built plain statements. - Multi-valued props survive together. All rows of one emission share a
last_seen, so all values of the latest emission are kept (ties at the group maximum), and all values of older emissions go. - The two modes are isolated. A non-fragment row never supersedes a fragment row or vice versa, even with identical content. The same statement can legitimately exist under multiple fragments (and additionally without one) –
fragmentis part of the stored row identity, but not part of the statementid. - Origins are isolated too. The same fragment written under two different origins forms two independent supersession groups, matching the
(shard, bucket, origin)partition scope ofmerge. Roles isolate the same way – two roles writing one fragment supersede independently. - Tombstones participate. A tombstone written with the fragment supersedes its group like any emission; the group disappears from queries immediately and is physically reaped once the tombstone passes the grace period.
delete_entityhandles this automatically – it reads each live row's fragment and writes fragment-matched tombstones.
Producer contract
All rows of one logical fragment emission must share the same last_seen timestamp – supersession keeps every row tied at the group's maximum, so jitter within an emission would keep only the very latest row and break multi-valued props. add_entity pins one timestamp per fragment emission (from the entity's last_seen / last_change, falling back to a single now); non-fragment emissions keep each statement's own last_seen (faithful provenance on store round-trips) and only fall back to the pinned value when unset. Statement-level producers assign one timestamp per batch themselves:
ts = datetime.now(timezone.utc).isoformat()
with entities.writer(origin="import") as writer:
for prop, value in row_values:
writer.add_statement(
Statement(entity_id=entity_id, prop=prop, value=value, schema=schema, dataset="my_dataset", last_seen=ts),
fragment=f"row{row_number}",
)
Note that the FtM statement model truncates last_seen to second granularity: two emissions of the same fragment within the same second tie, and both survive. Distinct emissions need distinct timestamps – re-processing loops faster than once per second should carry producer-assigned timestamps.
In storage, "no fragment" is the empty string, never NULL; the SDK translates fragment=None to '' at the boundary.
Deleting Entities
Deletes are tombstones routed through the journal (or EntityBuffer for the bulk path). They land in parquet as rows with deleted_at set. The default query view filters out tombstones via deleted_at IS NULL, so deleted entities disappear from query() and stream() as soon as merge has collapsed the live + tombstone pair.
Delete an Entity
count = entities.delete_entity("jane-doe")
print(f"Wrote {count} tombstones")
entities.flush()
entities.merge() # collapse live+tombstone → tombstone survives until grace
Delete a Single Statement
stmts = list(entities.query_statements())
target = stmts[0]
entities.delete_statement(target)
entities.flush()
entities.merge()
Re-adding After Delete
entities.delete_entity("jane-doe")
entities.flush()
entities.merge() # set LAKEHOUSE_GRACE_PERIOD_DAYS=0 to drop tombstones immediately
entities.add(updated_jane, origin="correction")
entities.flush()
# jane-doe is alive again with the new data
Deduplication
On write: identical statements collapse only inside one writer batch, where the in-memory buffer keys rows by (id, origin, fragment, role). The journal itself is append-only and keyless – re-emissions accumulate as rows.
Across flushes: re-flushing the same statement appends a new parquet row. The duplicates only collapse when merge runs. merge keeps the row with the latest last_seen per statement id and role (per supersession group for fragment rows) and folds first_seen to the minimum across the group.
entities.add(entity)
entities.flush() # one row in parquet
entities.add(entity)
entities.flush() # two rows now; same statement.id
entities.merge() # back to one row, last_seen=now, first_seen=original
Dedup is merge's job alone – there is no write-time collapse to lean on, so run merge on a schedule (or via optimize) and treat queries as accurate on an optimized store.
Maintenance
Three independent async operations on the parquet statement store, held under the exclusive maintenance fence so they never race each other or in-flight appends.
Flush (journal → parquet)
Claims the journal by rotating it away, then streams the rotated segment into parquet as Arrow batches. Journal rows carry no shard column – append derives it from entity_id against the dataset's current shard count, so a row journalled before a config change still lands where readers look for it – and the segment streams out unordered, so a batch becomes one parquet file per (shard, bucket, origin) partition it spans. Writers keep going against the fresh journal table throughout. No dedup happens here – duplicates and tombstones land as new rows for merge to collapse later.
From the CLI, per dataset or across the whole catalog:
Compact (cheap)
Bin-packs small parquet files within each (shard, bucket, origin) partition via Delta's OPTIMIZE compact. Does not change row contents.
Merge (expensive)
Per-partition rewrite that collapses duplicates, folds first_seen to the min across each group, and drops tombstones whose deleted_at is older than the grace cutoff. Non-fragment rows dedupe per statement id (ROW_NUMBER OVER (PARTITION BY id ORDER BY last_seen DESC) = 1); fragment rows keep the latest emission per (entity_id, prop, fragment) group.
Grace comes from LAKEHOUSE_GRACE_PERIOD_DAYS (default 30 days); set it to 0 to drop all tombstones immediately.
Vacuum
Deletes obsolete parquet files that merge / compact have tombstoned in the Delta log.
Complete Example
from ftm_lakehouse import ensure_dataset
from followthemoney import model
def create_person(name: str, nationality: str) -> model.EntityProxy:
entity = model.make_entity("Person")
entity.make_id(name)
entity.add("name", name)
entity.add("nationality", nationality)
return entity
def main():
dataset = ensure_dataset("people_dataset")
people = [
create_person("Jane Doe", "us"),
create_person("John Smith", "gb"),
create_person("Maria Garcia", "es"),
]
# Write
with entities.writer(origin="manual") as writer:
for person in people:
writer.add_entity(person)
count = entities.flush()
print(f"Flushed {count} statements")
# Maintenance – run on a schedule in production
entities._statements.compact()
entities.merge()
# Read back
jane = entities.get(people[0].id)
print(f"Found: {jane.caption}")
for entity in entities.query():
print(f" - {entity.caption}")
if __name__ == "__main__":
main()
Multiple Datasets
The catalog enumerates all datasets under one storage root:
from ftm_lakehouse import get_entities, get_lakehouse
catalog = get_lakehouse()
for name in catalog.list_datasets():
print(name, get_entities(name).stats())
Custom Dataset Models
Downstream applications can extend the dataset config schema by registering a
DatasetModel subclass process-wide – every config
read (repository construction, get_dataset_model, the index export)
constructs through it:
import ftm_lakehouse
class MyModel(ftm_lakehouse.DatasetModel):
user_id: int = 0
ftm_lakehouse.set_model_class(MyModel)
ftm_lakehouse.update_dataset("my_dataset", user_id=17)
assert ftm_lakehouse.get_dataset_model("my_dataset").user_id == 17
Call set_model_class() at process start, before any repository or config
access.