Extrinsic Provenance at Entry Tables¶
Overview¶
Rows that enter a pipeline from outside carry a hidden _prov attribute recording where they came from. The framework declares the attribute on Manual tables and fills it on insert; no author writes it.
New in 2.3.4
dj.Entry is available from 2.3.4 as a permanent alias for dj.Manual. This page uses dj.Manual throughout; both names declare the same table.
Motivation¶
Inside a pipeline, provenance is structural. A Computed table's row cannot exist unless its declared upstream exists and is correct, so the foreign-key graph is the lineage. Nothing has to be recorded for that to hold.
At the boundary the structure runs out. Rows arrive in Manual tables from a person, an instrument, or an automated feed, and the graph has nothing to say about where they came from. Before 2.3.4 each pipeline answered that question its own way โ a source_file column here, a notes varchar there, an ingestion log somewhere else, or nothing at all.
_prov gives the boundary one shape, so that two pipelines answer "where did this row come from" the same way, and so that "which rows have no recorded origin" is a query rather than an audit.
The Attribute¶
| Attribute | Type | Description |
|---|---|---|
_prov |
json (MySQL) / jsonb (PostgreSQL) |
Extrinsic provenance for a row that entered from outside the pipeline. NULL when nothing was recorded. |
Hidden attributes are prefixed with _: stored in the database, filtered out of heading.attributes, and excluded from query composition.
Why JSON rather than typed columns. The useful key set is not settled, and deployments add to it. A JSON document absorbs a new key as a configuration change; typed columns would freeze the set at declaration and make every later addition an ALTER across every Manual table in every deployment. Filtering on a field stays portable โ see Querying.
Which Tables Carry It¶
Manual tables only.
| Tier | Carries _prov |
Why |
|---|---|---|
dj.Manual |
Yes | The pipeline's boundary with the outside world |
dj.Lookup |
No | Rows come from the committed contents, so the code is the record |
dj.Imported |
No | See below |
dj.Computed |
No | Provenance is entailed by the foreign-key graph |
dj.Part |
No | A part inherits its master's |
The slot is granted by matching the Manual tier, not by excluding the other tiers' prefixes, so DataJoint's own system tables โ job queues, lineage โ never carry it either.
Why not Imported. An Imported table already records agent, time and version through job metadata when config.jobs.add_job_metadata is on, so a _prov there would record the same facts twice. The half that is not covered โ which specific file, endpoint, or instrument session its make() read โ is known per row inside the make() body, which configuration cannot supply.
In a well-modeled pipeline the external source is registered as a Manual row and the Imported table reaches it through a declared foreign key, which makes that table's provenance structural. An Imported table reading a source no Manual row records is the modeling problem described in Table Declaration; the fix is to register the source, not to add a slot.
Content¶
Three sources fill the attribute, and none of them is the insert call site.
| Source | Supplies |
|---|---|
| Configuration | config.provenance.source โ the deployment constant naming the external system this process draws from |
| Ambient connection state | the connecting user, host and database; the insert time; the code version |
| Ambient execution state | the ingesting table and key, when the insert runs inside a make() |
A recorded document:
{
"time": "2026-09-30T14:22:05.481203+00:00",
"agent": {"user": "ingest_svc", "host": "db.example.org", "database_name": "lab_subjects"},
"version": "a1b2c3d",
"source": {"system": "PyRat", "endpoint": "https://pyrat.example.org/api/v2"},
"context": {"table": "`lab`.`_ingest`", "key": {"file_id": 7}, "version": "a1b2c3d"}
}
Keys are omitted when they have nothing to report: source when none is configured, context outside a make(), version when config.jobs.version_method is disabled. When only time would remain the attribute is left NULL, because a bare timestamp says nothing about origin.
time is the client's UTC clock at insert, not the server's.
The author cannot write it¶
insert() takes no provenance argument, and passing _prov in a row raises KeyError โ hidden attributes are not in the heading.
This is the design rather than a limitation. A field an operator can set is weaker evidence than one the system sets, which is what attributable and contemporaneous require of externally-sourced data. It also removes the failure mode a supported-but-optional field would have: there is nothing left for a pipeline to neglect.
Anything an author wants to record deliberately belongs in the data model, as an ordinary visible attribute. Pipeline code can restrict and join on a modeled column; it cannot on a hidden one. The two do different jobs โ _prov is the audit record, a modeled column is the domain link. See Fan-Out Ingestion, where both appear side by side.
Configuration¶
| Setting | Environment | Default | Description |
|---|---|---|---|
provenance.capture |
DJ_PROVENANCE_CAPTURE |
False |
Declare _prov on Manual tables and fill it on insert |
provenance.source |
DJ_PROVENANCE_SOURCE |
{} |
External source identity recorded on every row this process enters |
import datajoint as dj
dj.config.provenance.capture # False until a deployment enables it
dj.config.provenance.source = {"system": "PyRat", "endpoint": "https://pyrat.example.org/api/v2"}
Deployments set these where they set stores and credentials, not in pipeline code:
export DJ_PROVENANCE_SOURCE='{"system": "PyRat", "endpoint": "https://pyrat.example.org/api/v2"}'
{
"provenance": {
"source": {"system": "PyRat", "endpoint": "https://pyrat.example.org/api/v2"}
}
}
Changing the source mid-process is silent
Rows inserted before the change keep what was configured then, and rows after keep the new value. Nothing records that the setting moved. Set it once at start-up.
Capture defaults off¶
Capture changes the DDL of every Manual table declared after it is enabled, adding one hidden nullable column. That is a deployment's decision rather than a library default, so upgrading to 2.3.4 leaves an unchanged schema declaring exactly what it declared under 2.3.3. jobs.add_job_metadata defaults off for the same reason, and does the same kind of thing.
A deployment that turns it on gets the property that makes the slot worth having: across that deployment, "which rows have no recorded origin" is a query rather than an audit. What it cannot assume is that a table declared elsewhere, under someone else's configuration, carries the column โ retrofitting is what settles that.
Behavior¶
At declaration¶
With provenance.capture true, _prov is added to the CREATE TABLE of every Manual table that is not a part. Turning capture off later does not remove the column from tables that already have it.
At insert¶
Every insert() and insert1() into a Manual table that carries the column appends the assembled document. A table declared without the column is left alone and the insert succeeds โ silently, which is what retrofitting addresses.
Inside make()¶
An insert executed inside a make() records the ingesting table and key in context. This is what makes the fan-out ingestion pattern traceable: rows written into Manual tables that carry no foreign key back to the loader still record what wrote them.
Retrofitting Existing Tables¶
Tables declared before 2.3.4, or while capture was off, have no column and record nothing. datajoint.deploy.add_prov_column adds the slot:
from datajoint.deploy import add_prov_column
# Preview
add_prov_column(schema, dry_run=True)["ddl"]
# Apply to every Manual table in a schema
add_prov_column(schema, dry_run=False)
# Or a single table
add_prov_column(Subject, dry_run=False)
It is idempotent โ a table that already has the column is reported and left alone โ and it lives in datajoint.deploy rather than datajoint.migrate for that reason.
Rows already present keep NULL. Provenance is recorded at insert and is never reconstructed after the fact.
Querying¶
_prov is excluded from heading.attributes, so it does not appear in to_dicts(), in describe(), or in a join.
Restricting on it works, written as a SQL condition string:
# Rows with no recorded origin โ portable
Subject & "_prov IS NULL"
Filtering on a field inside the JSON needs backend-specific SQL because the
attribute is hidden, not because JSON paths are awkward. On an ordinary JSON
attribute the mapping form is portable โ DataJoint translates {"data.system":
"PyRat"} to json_value() on MySQL and jsonb_extract_path_text() on
PostgreSQL. That translation is unavailable here only because the mapping form
cannot reach a hidden attribute (below):
# MySQL
Subject & "JSON_VALUE(_prov, '$.source.system') = 'PyRat'"
# PostgreSQL
Subject & "jsonb_extract_path_text(_prov, 'source', 'system') = 'PyRat'"
The mapping form does not reach a hidden attribute
Subject & {"_prov.system": "PyRat"} returns every row. A mapping
restriction ignores attributes it cannot match, which is deliberate and
useful โ it is what lets Session & key work when key carries attributes
from a more detailed table. A hidden attribute is invisible to that matching,
so the predicate is dropped along with it.
Write the condition as a string, which reaches the column directly โ at the cost of portability, since DataJoint's own JSON-path translation is what the mapping form would have given you. Tracked in datajoint-python#1561.
Reading the value back requires SQL until 2.4. There is no public API that
returns a hidden attribute โ to_arrays('_prov') and proj('_prov') both raise:
# Until 2.4
rows = Subject.connection.query(
f"SELECT subject_id, _prov FROM {Subject.full_table_name}"
).fetchall()
A supported accessor is planned for 2.4
(datajoint-python#1562),
which will cover _prov and the job-metadata attributes together.
Range queries on capture time
Filtering by time goes through JSON extraction, so it does not use an index. If that becomes hot for a deployment, add a generated column over the JSON path and index it; no change to the pipeline or to DataJoint is needed.
Implementation Details¶
| Module | Role |
|---|---|
datajoint/provenance.py |
payload assembly and the ingesting context |
datajoint/settings.py |
ProvenanceSettings, exposed as config.provenance |
datajoint/declare.py |
PROV_DEFINITION, and adding it to Manual tables at declaration |
datajoint/user_tables.py |
is_tier, the shared tier test |
datajoint/table.py |
appends the value on the insert path |
datajoint/autopopulate.py |
scopes the ingesting context to a make() call |
datajoint/deploy.py |
add_prov_column |
The column is declared the way a user attribute is โ
_prov = null : json # extrinsic provenance ... โ and compiled by the same
compile_attribute, so the backend mapping to json or jsonb comes from the
adapter's type system rather than from a per-adapter method. No adapter
implements anything of its own for it.
See Also¶
- Hidden Job Metadata โ the same hidden-attribute mechanism, for the automated tiers
- Record Data Origin โ the task-oriented guide
- Fan-Out Ingestion โ where a loader writes into tables that do not depend on it
- Comparison to Provenance Systems โ what DataJoint records and what it leaves to provenance systems