Skip to content

Transactions

A database is not merely a store of records; it is meant to reflect the current state of an enterprise faithfully, to everyone looking at it at once. Two analysts querying the same pipeline should see the same data, and a write made by one should immediately inform every read that follows. This property is called data consistency, and it is easiest to appreciate through its failures โ€” a bank website that shows yesterday's pending transactions but today's balance is briefly inconsistent, presenting two versions of the truth that do not agree.

Consistency is cheap when only one party writes and everyone else reads, which is common in science: one lab produces data, others analyze it. It becomes hard the moment several agents โ€” people or processes โ€” read and modify the same data concurrently. The mechanism that keeps a database consistent under concurrent, multi-step change is the transaction: a group of statements that the database treats as a single indivisible unit.

What a transaction guarantees

A transaction bundles several read and write operations so that they commit all-or-nothing. Either every operation in the group takes effect together, or โ€” if anything goes wrong โ€” none of them do, and the database is left exactly as it was before the group began. There is no observable state in which only some of the operations have landed.

Relational databases express this guarantee through the ACID properties. Grounded in DataJoint, they read as follows:

  • Atomic โ€” the group is indivisible. If any step fails or raises, the whole group is rolled back. This is the all-or-nothing behavior above.
  • Consistent โ€” each committed transaction moves the database from one valid state to another, with every integrity constraint โ€” primary keys, foreign keys, unique โ€” satisfied at the boundary. A transaction that would leave a dangling foreign key cannot commit.
  • Isolated โ€” concurrent transactions do not see one another's partial work. Each runs as if it had the database to itself, reading a consistent snapshot.
  • Durable โ€” once a transaction commits, its changes survive crashes and restarts.

DataJoint adheres to this classical ACID model on both of its peer backends, MySQL and PostgreSQL, relying on each backend's default isolation to keep the grouped statements atomic and consistent โ€” MySQL opens the transaction with a consistent snapshot (REPEATABLE READ), PostgreSQL uses READ COMMITTED. The database engine, not DataJoint, ultimately enforces the guarantee; DataJoint's job is to open a transaction around the right group of statements.

A concrete all-or-nothing example

The textbook case is a transfer between two accounts. Consider a simple table of bank accounts:

import datajoint as dj

schema = dj.Schema("bank")

@schema
class Account(dj.Manual):
    definition = """
    account_number : int32
    ---
    customer_name  : varchar(60)
    balance        : decimal(9, 2)
    """

Moving money requires two writes that belong together: debit the source, credit the destination. Written naively, without grouping them, a failure between the two is catastrophic:

def transfer_unsafe(source, destination, amount):
    balance = (Account & source).fetch1("balance")
    if balance < amount:
        raise RuntimeError("Insufficient funds")
    Account.update1(dict(source, balance=balance - amount))   # money leaves here
    raise RuntimeError("connection lost")                     # ...and never arrives
    Account.update1(dict(destination, balance=... + amount))  # never runs

The debit committed on its own; the credit never happened. Money vanished, and no constraint was violated โ€” the database is internally valid but factually wrong. Wrapping the two writes in a transaction closes this gap:

def transfer(source, destination, amount):
    if amount <= 0:
        raise ValueError("Transfer amount must be positive")
    with schema.connection.transaction:
        balance = (Account & source).fetch1("balance")
        if balance < amount:
            raise RuntimeError("Insufficient funds")
        Account.update1(dict(source, balance=balance - amount))
        dest = (Account & destination).fetch1("balance")
        Account.update1(dict(destination, balance=dest + amount))

Everything inside with schema.connection.transaction: is one atomic unit. If the block completes normally, both updates commit together. If any statement raises โ€” an insufficient-funds check, a lost connection, a bug โ€” the whole block rolls back and the exception propagates. The debit is never left stranded without its credit.

The same pattern makes a master row and its part rows land together. Because a part table's rows depend on their master through a foreign key, inserting them as one transaction guarantees you never observe a master without its parts, nor parts orphaned from a master:

with schema.connection.transaction:
    Experiment.insert1(dict(experiment_id=1, experiment_date="2026-07-17"))
    Experiment.Trial.insert([
        dict(experiment_id=1, trial_idx=1, outcome="success"),
        dict(experiment_id=1, trial_idx=2, outcome="failure"),
    ])

A single insert() or update1() call is already atomic on its own. You reach for an explicit with schema.connection.transaction: block only when several operations must succeed or fail as a group, as above.

Which connection the transaction uses

schema.connection is the connection shared by that schema's tables โ€” the same object dj.conn() returns in the common single-connection setup. A transaction covers only operations on that connection, so group tables that share it. If you deliberately place schemas on different connections (separate dj.Instances, or an explicit connection=), wrap the work using the connection those tables actually use.

Transactions do not nest

DataJoint does not support nested transactions. Opening a transaction while another is already active on the same connection raises an error. Keep exactly one transaction block active at a time.

Transactions in computation

You almost never write a transaction block inside a make() method, because populate() has already opened one for you. For each key it processes, populate() wraps the entire make(key) call in a transaction, calls make(), and commits only if it returns without error. Everything a make() writes โ€” its own rows and all of its part-table rows โ€” is therefore inserted as one atomic unit. A computed result and its parts commit together, or, if make() raises, roll back together, leaving nothing behind to clean up.

@schema
class ProcessedData(dj.Computed):
    definition = """
    -> RawData
    ---
    result : float64
    """

    def make(self, key):
        data = (RawData & key).fetch1("data")
        result = expensive_computation(data)
        self.insert1(dict(key, result=result))   # already inside populate()'s transaction

This is why DataJoint pipelines stay consistent with their inputs: a downstream result is either fully present or fully absent, never half-computed.

Never open a transaction inside make()

Because populate() has already started one, opening your own transaction inside make() attempts to nest โ€” which DataJoint does not support, so it raises an error. Just insert directly; the write is already atomic.

def make(self, key):
    with self.connection.transaction:   # WRONG โ€” a transaction is already active
        self.insert1(...)         # this raises an error

For long-running computations, holding the transaction open for the whole make() would lock resources and risk timeouts. The three-part make pattern addresses this by running fetch and compute outside the transaction and confining only the insert to a brief one โ€” still without you ever opening a transaction yourself.

Deletes are transactional too

Cascading deletes span many tables โ€” a delete propagates through every dependent table in reverse topological order. To keep that cascade all-or-nothing, delete runs inside a transaction by default (transaction=True), so a failure partway through rolls back the entire cascade rather than leaving descendants deleted and ancestors intact. The only reason to opt out with delete(transaction=False) is when the delete is already running inside a transaction you opened, since a nested transaction is not allowed.

See also

  • Computation Model โ€” how populate() wraps each make() call and the three-part pattern for long computations.
  • Data Integrity โ€” the integrity guarantees transactions uphold at each commit boundary.
  • Insert data โ€” inserting master and part rows.
  • Delete data โ€” cascading deletes and safemode.
  • AutoPopulate โ€” normative spec for the per-key transaction and the make() reproducibility contract.