Skip to content
About
Main content

Trigger overhead

Run a real systemNeeds Docker, Python

Problem

Trigger-based captures changes inside the database: a on the table writes every insert, update and delete into a , in the same transaction. It sees every change, but it pays on the write path, synchronously: each statement now does a second insert, builds a JSON copy of the row, and touches a second table's indexes and locks. And a set-based statement is no longer set-based, because a row-level trigger fires once per row.

In PostgreSQL, time 200,000 single-row inserts into orders with and without the shadow-table trigger from Detect (use pgbench with a one-statement script, or a short Python loop). Repeat with one bulk INSERT ... SELECT of 200,000 rows and note that the trigger turns it into 200,000 row-by-row executions.

The script uses a Python loop with one statement and one per row, and the trigger exactly as the How it works article defines it.

The question. How much slower does the shadow-table trigger make a single-row insert, and a bulk insert of the same rows?


Predict

How much slower does the shadow-table trigger make a single-row insert?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you. This lab also runs a real system, so you need Docker.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    trigger-overhead.py91 linesDownload
    View the code
    trigger-overhead.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["psycopg[binary]"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Trigger overhead.
    
    Single-row inserts into orders, and one bulk INSERT ... SELECT, with and
    without the shadow-table trigger that records every change as JSON.
    Needs the PostgreSQL service from the compose file (port 55433).
    """
    import argparse, json, time
    import psycopg
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55433/postgres", help="Connection string.")
    p.add_argument("--rows", type=int, default=200_000, help="Rows inserted, one statement each, and in the bulk insert.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    SHADOW = """
    CREATE TABLE orders_changes (
        change_id  bigserial PRIMARY KEY,
        op         char(1),            -- I, U or D
        order_id   bigint,
        old_row    jsonb,
        new_row    jsonb,
        changed_at timestamptz DEFAULT now()
    );
    CREATE FUNCTION capture_orders() RETURNS trigger AS $$
    BEGIN
      IF TG_OP = 'INSERT' THEN
        INSERT INTO orders_changes(op, order_id, new_row)
        VALUES ('I', NEW.order_id, to_jsonb(NEW));
      ELSIF TG_OP = 'UPDATE' THEN
        INSERT INTO orders_changes(op, order_id, old_row, new_row)
        VALUES ('U', NEW.order_id, to_jsonb(OLD), to_jsonb(NEW));
      ELSE
        INSERT INTO orders_changes(op, order_id, old_row)
        VALUES ('D', OLD.order_id, to_jsonb(OLD));
      END IF;
      RETURN NULL;
    END $$ LANGUAGE plpgsql;
    CREATE TRIGGER orders_cdc AFTER INSERT OR UPDATE OR DELETE ON orders
    FOR EACH ROW EXECUTE FUNCTION capture_orders();
    """
    
    con = psycopg.connect(args.dsn, autocommit=True)
    
    def reset(trigger):
        con.execute("DROP TABLE IF EXISTS orders, orders_changes CASCADE; DROP FUNCTION IF EXISTS capture_orders CASCADE")
        con.execute("CREATE TABLE orders (order_id bigint PRIMARY KEY, customer_id bigint, status text, total numeric)")
        if trigger: con.execute(SHADOW)
    
    def single_rows():
        t = time.perf_counter()
        with con.cursor() as cur:
            for i in range(args.rows):                    # one statement, one commit per row (autocommit)
                cur.execute("INSERT INTO orders VALUES (%s, %s, 'placed', %s)", (i, i % 1000, i % 97))
        return time.perf_counter() - t
    
    def bulk():
        t = time.perf_counter()
        con.execute(f"INSERT INTO orders SELECT i, i % 1000, 'placed', i % 97 FROM generate_series(1, {args.rows}) i")
        return time.perf_counter() - t
    
    results = {}
    for trigger in (False, True):
        reset(trigger); s = single_rows()
        reset(trigger); b = bulk()
        results[trigger] = (s, b)
        label = "with trigger" if trigger else "no trigger  "
        print(f"{label}: {args.rows:,} single-row inserts {s:6.1f} s ({s / args.rows * 1e6:5.0f} µs each)   "
              f"bulk INSERT ... SELECT {b:5.2f} s")
    changes = con.execute("SELECT count(*) FROM orders_changes").fetchone()[0]
    print(f"\nper statement the trigger costs {results[True][0] / results[False][0]:.1f}x; "
          f"on the bulk insert {results[True][1] / results[False][1]:.1f}x "
          f"({changes:,} shadow rows written, one per inserted row)")
    ver = con.execute("SHOW server_version").fetchone()[0]
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {
                "timings": [{"mode": m, "statement": st, "seconds": results[tr][i]}
                            for tr, m in ((False, "no trigger"), (True, "with trigger"))
                            for i, st in ((0, "single-row inserts"), (1, "bulk INSERT ... SELECT"))],
                "summary": [{"single_ratio": results[True][0] / results[False][0], "bulk_ratio": results[True][1] / results[False][1],
                             "single_us_plain": results[False][0] / args.rows * 1e6, "single_us_trigger": results[True][0] / args.rows * 1e6,
                             "shadow_rows": changes, "server_version": ver}]}}, f)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    trigger-overhead.compose.yml12 linesDownload
    View the code
    trigger-overhead.compose.yml
    # PostgreSQL for the trigger-overhead lab.
    services:
      postgres:
        image: postgres:17.6
        environment:
          POSTGRES_PASSWORD: lab
        ports: ["55433:5432"]
        healthcheck:
          test: ["CMD", "pg_isready", "-U", "postgres"]
          interval: 1s
          retries: 30
    
  4. 4Start the services, run the script with the defaults first, then change a knob from the table below and run it again. Remove the services when you are done.

    Terminal
    docker compose -f trigger-overhead.compose.yml up -d --wait
    uv run trigger-overhead.py
    uv run trigger-overhead.py --help     # every knob
    docker compose -f trigger-overhead.compose.yml down -v
Knobs
Knob · defaultWhat it changes
--dsnpostgresql://postgres:lab@localhost:55433/postgres Connection string.
--rows200,000 Rows inserted, one statement each, and in the bulk insert.

Our run

200,000 rows, with and without the trigger

200,000 rows, with and without the trigger
TriggerStatementTime
no triggersingle-row inserts40 s
no triggerbulk INSERT ... SELECT237 ms
with triggersingle-row inserts42.2 s
with triggerbulk INSERT ... SELECT1.12 s
Reference runApple M2 · 8 cores · 8 GB
Output · uv run trigger-overhead.py
no trigger  : 200,000 single-row inserts   40.0 s (  200 µs each)   bulk INSERT ... SELECT  0.24 s
with trigger: 200,000 single-row inserts   42.2 s (  211 µs each)   bulk INSERT ... SELECT  1.12 s

per statement the trigger costs 1.1x; on the bulk insert 4.7x (200,000 shadow rows written, one per inserted row)
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

What we found

Per single-row statement the trigger cost 1.05×: 211 µs per insert against 200 µs without it. That is below the expected 2–5×, because each of these statements also pays a network round trip and a commit, which the trigger does not change; the trigger's own work is a smaller share of a statement that is mostly overhead.

The bulk insert is where it shows: one INSERT ... SELECT of 200,000 rows became 4.73× slower with the trigger, and the shadow table received 200K rows, one per inserted row. A statement the database could run as one set operation now runs the trigger body once per row.


Read it

The write amplification lands on the write path, synchronously. You have also put a second writer inside every transaction: the shadow table's hot rows, indexes and locks join the source's contention surface. Triggers are the right tool when the log is not available to you (a managed database that hides it, an engine with no ), and the wrong one on a table with heavy bulk loads.