Resource library

QA How-To

SQL for test data setup and teardown (2026)

Use SQL for test data setup and teardown with safe transactions, fixtures, foreign keys, parallel isolation, cleanup guards, and runnable PostgreSQL tests.

25 min read | 3,205 words

TL;DR

SQL for test data setup and teardown is safest when each test owns a small, valid dataset and cleanup is guaranteed. Use transaction rollback for same-process database tests. Use isolated schemas, tenants, or run markers plus exact foreign-key-aware deletes when the application commits through another connection. Guard hosts, capture returned IDs, and verify no rows leak.

Key Takeaways

  • Prefer one transaction per test and rollback when the system under test can share that database transaction.
  • Use generated identifiers returned by the database instead of fixed IDs or assumptions about sequence values.
  • Create only the smallest valid dependency graph and keep expected IDs in fixtures for exact assertions.
  • For black-box or multi-process tests, isolate by database, schema, tenant, or test-run marker and clean in foreign-key order.
  • Make cleanup idempotent, narrowly scoped, observable, and impossible to run against an unapproved database.
  • Handle savepoints, deferred constraints, triggers, sequences, caches, jobs, and external side effects explicitly.
  • Test the data harness itself because a green suite built on leaked or shared state is not trustworthy.

SQL for test data setup and teardown should make every database test independent, repeatable, and safe to run after another test fails. The simplest reliable pattern is to begin a transaction, insert the minimum valid rows, execute the behavior, assert state, and roll back. That pattern changes when the application uses another connection, commits asynchronously, or creates external side effects, so a mature suite needs more than a final DELETE FROM statement.

This guide explains transaction fixtures, dependency builders, foreign-key-aware cleanup, savepoints, parallel isolation, and environment guards. The runnable PostgreSQL and pytest example uses current public psycopg APIs and returned identifiers rather than fixed sequence assumptions. The design principles also apply to JUnit, TestNG, Playwright API tests, and other relational databases with syntax adjustments.

TL;DR

Test shape Preferred isolation Teardown Main limitation
Repository test, one connection Transaction per test Rollback Application must share the transaction
Service component, several connections Schema or database per worker Drop isolated namespace Provisioning and migration cost
API black-box test Unique tenant or run marker Exact ordered delete or cleanup API App commits cannot be rolled back by test
Destructive migration test Disposable database clone Destroy database Slower setup
Shared staging test Unique natural marker and ownership tag Idempotent targeted cleanup Residual contention and background jobs

The rule is simple: create data you can identify exactly, and never clean data you cannot prove belongs to the current test run.

1. Design SQL for Test Data Setup and Teardown as a Harness

Test data code is production-quality infrastructure. It decides whether a failure represents the product or yesterday's residue. Treat it as a small harness with a lifecycle: validate environment, allocate isolation, install or verify schema, start transaction, create dependencies, expose identifiers, execute the test, capture diagnostics, clean effects, verify cleanup when valuable, and release connections.

Define ownership at three levels. A test case owns the rows needed for one behavior. A worker owns its connection, schema, tenant, or database namespace. The suite owns schema installation and eventual removal. Avoid a single global current_user_id or shared fixture row that parallel tests mutate. Shared immutable reference data can be acceptable if tests never depend on execution order and do not change it.

Prefer intent-revealing helpers such as create_customer(status='active') or given_pending_order() over long anonymous insert scripts. The helper should set safe defaults, accept meaningful overrides, return database-generated IDs, and keep SQL visible enough to review. A giant seed dump creates unrelated dependencies and makes it hard to know which field caused a failure.

Decide whether setup goes through SQL or a public API. Direct SQL is fast and precise for repository and service tests, but it can bypass application validation, encryption, event publication, and derived data. Use the public path when those behaviors are part of the precondition. It is reasonable to create a customer through a fixture API, then use SQL to verify a postcondition. Document what each shortcut intentionally bypasses.

For a broader governance model, see test data management guide.

2. Choose Between Rollback, Delete, Truncate, and Drop

Transaction rollback is the default for tests that can keep setup, behavior, and assertions on one connection or enlisted transaction. It is fast, restores updates and deletes as well as inserts, and works even when the assertion fails. It does not reverse sequence allocation, calls to external systems, or effects committed through other connections. It can also hide behavior that occurs only at commit.

Teardown method Strength Risk or limitation Best use
ROLLBACK Exact and fast for transactional rows Cannot undo other connections or external effects Repository and in-process component tests
Targeted DELETE Works after committed black-box actions Must respect ownership and foreign keys API tests with run markers
TRUNCATE Fast whole-table clearing Broad, lock-heavy, unsafe in shared databases Dedicated disposable database only
Drop schema Strong namespace isolation Requires schema-aware app and migration setup Parallel workers
Drop database or container Cleans everything Provisioning cost Migration, restore, destructive integration tests

Targeted delete is safer than broad delete only when predicates are precise. DELETE FROM orders WHERE created_at > now() - interval '1 hour' can erase another run's data. Use a generated test_run_id, tenant ID, unique email domain, or recorded primary keys. Delete children before parents unless cascades are intentionally tested.

Truncate and drop operations require an environment that belongs entirely to the suite. Add database-name, host, and marker-table guards. A variable named TEST_DATABASE_URL is not proof that its value is safe. Query server identity and a sentinel such as environment_metadata.is_test = true before destructive setup. Fail closed when the guard cannot be verified.

3. Build the Smallest Valid Dependency Graph

Relational data usually has dependencies. An order needs an account, perhaps a product and address, then order lines. Create only what the behavior requires. If every test imports a complete enterprise seed with users, catalogs, promotions, invoices, shipments, and audits, the suite becomes slow and accidental coupling grows.

Represent setup as a directed graph. Parent fixtures return identifiers to children. Insert parents first, capture RETURNING values, and pass them as parameters. Do not hard-code primary key 1 or reset a sequence so a test can predict it. PostgreSQL sequences and identity generators can advance outside transaction rollback. Gaps are normal and should not fail tests.

Use explicit column lists in every insert. INSERT INTO account VALUES (...) breaks when a column is added or reordered and conceals intent. Set only relevant overrides, while database defaults produce ordinary timestamps and statuses. If time matters, inject a business clock or pass a specific value. Keep all SQL parameterized. Values belong in parameters, not string interpolation. Identifiers require database-specific safe identifier composition, not value placeholders.

Builders need validation. If create_order(total_cents=-1) is meant to support a negative constraint test, make that escape explicit. Ordinary helpers should create valid state by default and refuse contradictory overrides. Return a structured object containing IDs and values actually stored, so assertions do not query by vague descriptions. For API-oriented fixture design, API test data management adds tenant, token, and cleanup patterns.

4. Use Transactions, Savepoints, and Commit-Aware Tests

A function-scoped transaction creates a clean snapshot and guarantees rollback in finally. Assertions can query uncommitted changes on the same connection. Application code must receive that connection or transaction. If it opens a new connection, it may not see setup under typical isolation and its committed writes will survive the test rollback. This is a frequent false assumption in service tests.

Savepoints isolate an expected statement failure without aborting the outer transaction. PostgreSQL marks a transaction failed after a constraint violation. Create a savepoint, execute the negative statement, assert the specific database error, then roll back to the savepoint before making more queries. Framework transaction contexts can manage savepoints, but understand their nesting behavior.

Commit-aware behavior needs a different test. Deferred constraints, transactional event listeners, outbox dispatch, and database notifications may occur at commit. A test that always rolls back before commit cannot prove them. Run these cases in an isolated schema or database, commit deliberately, assert the effect, and perform exact cleanup afterward. Keep them separate from the faster rollback suite.

Deadlocks, serialization failures, and unknown commit outcomes also require real concurrent transactions. Coordinate connections at the lock boundary, capture SQLSTATE or driver exception class, and verify the application's retry policy. Do not assume one transaction always wins. Assert a valid final state and bounded attempts. Cleanup should begin only after all worker transactions have finished or been canceled.

5. Create Deterministic SQL Fixtures and Builders

Deterministic does not mean every test uses identical values. It means values can be reconstructed from the run, worker, and case. Generate a run ID once, derive unique emails such as refund-worker2-case7@example.test, and record primary keys returned by SQL. Seed random generators when random variety is useful, and print the seed on failure.

Separate reference fixtures from scenario fixtures. Reference data includes immutable currencies or product types. Scenario data encodes the case: one active account, one pending order, and one declined payment attempt. A scenario helper should show business intent, while lower-level SQL builders handle columns and dependencies. Version the reference seed with migrations so application expectations and data remain compatible.

Time is another dependency. Database defaults such as CURRENT_TIMESTAMP use the database clock, while application code may use a process clock. For expiry and ordering tests, provide explicit UTC timestamps far enough apart to avoid precision ambiguity, or control both clocks. Test database column precision and time-zone conversion directly. Do not assert string formatting when the business requirement is temporal order.

Keep fixtures idempotent only where reuse is intended. INSERT ... ON CONFLICT DO NOTHING can hide that a previous test leaked the same row. Prefer plain inserts for case-owned records so collisions fail visibly. Upsert is appropriate for immutable suite reference data when the exact values are verified afterward. A setup pass should never silently accept a conflicting row with different attributes.

6. Run PostgreSQL Setup and Rollback Tests with pytest

Start a disposable PostgreSQL 18 container with docker run --rm --name qa-postgres -e POSTGRES_PASSWORD=qa -e POSTGRES_DB=qa_tests -p 5432:5432 -d postgres:18-alpine. Install pytest and psycopg[binary], set TEST_DATABASE_URL=postgresql://postgres:qa@127.0.0.1:5432/qa_tests, save this as test_orders.py, and run pytest -q.

import os
from urllib.parse import urlparse

import psycopg
import pytest

DATABASE_URL = os.environ.get(
    "TEST_DATABASE_URL",
    "postgresql://postgres:qa@127.0.0.1:5432/qa_tests",
)

SCHEMA = [
    """
    CREATE TABLE IF NOT EXISTS accounts (
        id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
        email text NOT NULL UNIQUE,
        balance_cents bigint NOT NULL CHECK (balance_cents >= 0)
    )
    """,
    """
    CREATE TABLE IF NOT EXISTS orders (
        id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
        account_id bigint NOT NULL REFERENCES accounts(id),
        status text NOT NULL CHECK (status IN ('pending', 'cancelled', 'paid')),
        total_cents bigint NOT NULL CHECK (total_cents > 0)
    )
    """,
]

def assert_safe_database(url):
    parsed = urlparse(url)
    if parsed.hostname not in {"localhost", "127.0.0.1"}:
        raise RuntimeError(f"Refusing database tests against host {parsed.hostname}")
    if parsed.path != "/qa_tests":
        raise RuntimeError(f"Refusing database tests against database {parsed.path}")

@pytest.fixture(scope="session", autouse=True)
def install_schema():
    assert_safe_database(DATABASE_URL)
    with psycopg.connect(DATABASE_URL, autocommit=True) as connection:
        for statement in SCHEMA:
            connection.execute(statement)

@pytest.fixture()
def db():
    connection = psycopg.connect(DATABASE_URL)
    connection.execute("BEGIN")
    try:
        yield connection
    finally:
        connection.rollback()
        connection.close()

@pytest.fixture()
def pending_order(db):
    account_id = db.execute(
        """
        INSERT INTO accounts (email, balance_cents)
        VALUES (%s, %s)
        RETURNING id
        """,
        ("case-cancel@example.test", 5000),
    ).fetchone()[0]
    order_id = db.execute(
        """
        INSERT INTO orders (account_id, status, total_cents)
        VALUES (%s, %s, %s)
        RETURNING id
        """,
        (account_id, "pending", 1200),
    ).fetchone()[0]
    return {"account_id": account_id, "order_id": order_id}

def test_pending_order_can_be_cancelled(db, pending_order):
    changed = db.execute(
        """
        UPDATE orders
        SET status = 'cancelled'
        WHERE id = %s AND status = 'pending'
        RETURNING status
        """,
        (pending_order["order_id"],),
    ).fetchone()

    assert changed == ("cancelled",)
    saved = db.execute(
        "SELECT status FROM orders WHERE id = %s",
        (pending_order["order_id"],),
    ).fetchone()
    assert saved == ("cancelled",)

def test_negative_balance_is_rejected_without_losing_outer_transaction(db):
    db.execute("SAVEPOINT invalid_balance")
    with pytest.raises(psycopg.errors.CheckViolation):
        db.execute(
            "INSERT INTO accounts (email, balance_cents) VALUES (%s, %s)",
            ("invalid@example.test", -1),
        )
    db.execute("ROLLBACK TO SAVEPOINT invalid_balance")

    remaining = db.execute(
        "SELECT count(*) FROM accounts WHERE email = %s",
        ("invalid@example.test",),
    ).fetchone()[0]
    assert remaining == 0

The fixture always rolls back, including after assertion failure. RETURNING captures generated identity values, and parameter binding protects values. The database guard is intentionally strict for a local tutorial. A team environment should also verify a sentinel table and dedicated credential before running migrations, truncation, schema drops, or broad cleanup.

7. Clean Foreign Keys, Cascades, Triggers, and Sequences Safely

Black-box API tests often cannot share the application's transaction. Give every created root a run marker or dedicated tenant, then delete only owned rows. Build cleanup from schema relationships: child rows, join tables, audit or outbox rows if policy permits, then parents. Execute cleanup in its own transaction and roll back the cleanup itself if any step fails, so the suite does not leave a half-cleaned graph.

Cascading deletes simplify syntax but can hide important effects. Verify the foreign key actually uses the intended cascade, restrict, set-null, or no-action behavior. A test cleanup must not rely on cascade if production intentionally restricts deletion. For dedicated ephemeral databases, truncating all owned tables with identity restart may be acceptable, but it should never run in a shared environment.

Triggers can create additional records during setup or cleanup. An account insert may write an audit row, and a delete may enqueue work. Query those tables when they matter. Disabling triggers for faster fixtures changes behavior and should be limited to tests that explicitly do not claim trigger coverage. It can also require elevated permissions that make the harness less production-like.

Sequence values are not row counts. Rollback and failed inserts can consume values, and parallel workers interleave them. Do not reset sequences per test or assert consecutive IDs. Assert uniqueness, referential correctness, and returned identity. If a business-facing number must be gapless, it needs a separate design and concurrency test rather than assumptions about a database sequence.

8. Isolate Parallel and Black-Box Database Tests

Parallel execution turns accidental sharing into nondeterminism. Choose one boundary per worker: database, schema, tenant, or row-level run marker. A database per worker gives the strongest isolation but costs more to provision. A schema per worker works when every connection reliably sets its search path or schema. Tenant isolation is realistic for multi-tenant applications but also exercises authorization. Row markers are lightweight and require flawless filtering.

Name resources from sanitized worker IDs and run IDs. SQL parameters cannot represent identifiers such as schema names. Use the driver's identifier-composition API, such as psycopg.sql.Identifier, and restrict allowed characters. Never concatenate an untrusted CI branch name into DROP SCHEMA. Keep lifecycle ownership clear so one worker cannot drop another worker's namespace.

Black-box tests need setup visibility. If direct SQL inserts data that the application caches, trigger the supported cache invalidation or create through the application. If a search projection updates asynchronously, wait through the product query with a bounded poll. Teardown must consider background workers that may recreate or modify records after deletion. Stop or namespace the worker, wait for terminal job state, then clean.

Use unique business values even with isolated rows. Email, external reference, idempotency key, and order number collisions can occur through global constraints. Derive values from the run and record them for diagnostics. SQL interview questions for QA provides query practice, but production-grade isolation also requires lifecycle and environment reasoning beyond SQL syntax.

9. Handle Failures, Diagnostics, and External Side Effects

Teardown runs after failures, so place it in framework finalizers or finally blocks. Preserve the original assertion failure if cleanup also fails, but report both. A cleanup exception must not overwrite the product defect. Capture owned IDs, transaction state, and exact cleanup stage. Do not dump entire tables or customer data into CI logs.

Make cleanup idempotent. DELETE ... WHERE id = %s can safely affect zero rows when an earlier step already removed the record. Assert an expected range when it helps detect leaks, but allow known absence. For a graph of records, record created IDs as each setup statement succeeds so partial setup can still be cleaned. A teardown that assumes the final parent was created will miss earlier children after setup fails midway.

SQL rollback cannot undo emails, files, queue messages, cache entries, provider calls, or committed work from another service. Use test sinks, namespaced object keys, idempotency keys, and provider sandbox cleanup. If a database row triggers a background job, do not roll it back before the worker can see it and then claim job coverage. Commit in an isolated environment and clean both database and external effects.

Build leak detection. At suite end, query for the current run marker across owned tables and fail or alert if rows remain. In ephemeral environments, archive concise diagnostics and destroy the database. In shared staging, schedule a separate janitor by explicit ownership and age, but do not let it make case teardown optional. Database testing interview questions contains additional ways to explain transactions, constraints, and integrity in interviews.

10. Apply a SQL for Test Data Setup and Teardown Checklist

Before the suite, verify the exact host, database, user, environment sentinel, migration version, and allowed destructive operations. Allocate a unique run and worker scope. Decide which data is immutable reference data and which belongs to cases. Prevent ordinary test credentials from modifying schemas or unrelated tenants when those permissions are not required.

For setup, use explicit columns, parameterized values, returned generated IDs, minimal dependencies, valid defaults, deterministic unique markers, and controlled time. Keep negative fixtures explicit. Verify setup through the layer the test needs, especially when triggers, caches, projections, or encryption can transform the stored record.

For behavior and teardown, choose rollback when all work shares the transaction. Otherwise record IDs and markers, stop asynchronous work, delete in dependency order inside a cleanup transaction, clear external artifacts, and verify the owned scope is empty. Handle partial setup and repeat cleanup safely. Never use age-only predicates or unguarded truncation in a shared database.

Finally, test the harness. Force a setup failure after the first insert, an assertion failure, a cleanup statement failure, parallel collisions, and a canceled test process. Confirm finalizers run where possible, leaks are detected, and ephemeral resources are destroyed by CI even when tests fail. A reliable database suite is not just a set of SQL assertions. It is a lifecycle system that makes false passes and unsafe cleanup difficult.

Interview Questions and Answers

Q: What is the safest teardown strategy for database tests?

A transaction rollback is safest and fastest when setup, application behavior, and assertions share the same transaction. For black-box tests that commit through another connection, I use isolated namespaces or exact run markers and foreign-key-aware cleanup.

Q: Why should tests not hard-code database IDs?

Identity and sequence values can advance after rollback, failed statements, and parallel inserts. I capture generated IDs with RETURNING or the driver equivalent and pass them through fixtures. Tests should assert relationships, not consecutive numbers.

Q: When is direct SQL setup inappropriate?

It is inappropriate when the precondition must exercise application validation, encryption, events, projections, or other behavior that SQL bypasses. I use the public path for those rules and direct SQL only for safe, documented shortcuts.

Q: How do you test a constraint violation without aborting the whole PostgreSQL test transaction?

I create a savepoint, execute the invalid statement, assert the specific error, and roll back to the savepoint. The outer transaction then remains usable for postcondition checks and final rollback.

Q: How do you isolate parallel SQL tests?

I allocate a database, schema, tenant, or run marker per worker and generate unique business values. Each worker owns its lifecycle, and identifiers are composed safely rather than interpolated. The strongest affordable boundary depends on the application.

Q: Why can rollback tests miss defects?

Some constraints and listeners act at commit, and external or separate-connection effects are not rolled back. I maintain a smaller commit-aware suite in a disposable namespace for those behaviors.

Q: How do you make teardown safe after partial setup?

I record every created identifier as soon as it exists and run cleanup from a finalizer. Cleanup is idempotent, ordered by dependencies, and guarded to the current run. A second cleanup error is reported without hiding the original failure.

The interviewQnA field mirrors these concepts in concise model answers.

Common Mistakes

  • Using one shared account or order that tests update in parallel.
  • Hard-coding primary key 1 or assuming rollback restores sequence numbers.
  • Running direct SQL setup when the precondition depends on application events or encryption.
  • Calling DELETE FROM table or TRUNCATE without a dedicated database and strong guards.
  • Cleaning by recent timestamp instead of exact run ownership.
  • Deleting parents before children without a verified cascade policy.
  • Swallowing setup or teardown errors and allowing a misleading green result.
  • Letting cleanup failure overwrite the original product assertion.
  • Using string interpolation for values or dynamic schema identifiers.
  • Assuming test rollback reverses writes committed by another connection or service.
  • Skipping commit-aware tests for deferred constraints, outbox events, and notifications.
  • Ignoring caches, files, messages, background jobs, and provider effects created from database rows.

Conclusion

SQL for test data setup and teardown is reliable when ownership is explicit and cleanup matches the execution boundary. Use rollback for same-transaction tests. Use disposable databases, schemas, tenants, or precise run markers when the application commits independently. Capture generated IDs, honor dependencies, and protect every destructive operation with environment guards.

Begin by replacing shared fixture rows and broad deletes with a function-scoped transaction or isolated case builder. Then add commit-aware coverage, parallel namespaces, leak detection, and failure-path tests for the harness itself. Clean data is not housekeeping. It is part of the evidence that every test result can be trusted.

Interview Questions and Answers

How do you choose a database teardown strategy?

I identify where commits happen. If the test and application share a transaction, rollback is fast and exact. If another process commits, I isolate by database, schema, tenant, or run marker and perform targeted cleanup.

Why use RETURNING in test data builders?

It captures the identifier the database actually generated without assuming sequence state. I pass that ID to dependent fixtures and assertions. This keeps tests safe under rollback, failure, and parallel execution.

How do you handle expected constraint errors in PostgreSQL tests?

I open a savepoint, execute the invalid statement, assert the specific database error, and roll back to the savepoint. PostgreSQL otherwise leaves the transaction aborted after the error. The outer transaction can then continue and roll back normally.

What is wrong with deleting test data by timestamp?

A time range does not prove ownership and can include another test or real staging data. I use exact primary keys, a dedicated tenant, or a run identifier. Cleanup fails closed if the environment or ownership cannot be verified.

When should setup go through an API instead of SQL?

Use the API when validation, permissions, encryption, events, caches, or projections are part of the precondition being trusted. Direct SQL is appropriate for intentional lower-level setup shortcuts. I document what it bypasses.

How do you test transaction behavior that occurs on commit?

I use an isolated schema or database, commit deliberately, and assert deferred constraints, outbox entries, listeners, or notifications after commit. Then I clean exact records. A suite that always rolls back cannot prove commit-only behavior.

How do you keep teardown from hiding the original test failure?

Cleanup runs in a finalizer and captures its own error separately. The report preserves the original assertion as primary while adding cleanup diagnostics and owned IDs. Idempotent cleanup and suite-level leak checks provide a second recovery path.

Frequently Asked Questions

What is the best SQL test data teardown method?

Use transaction rollback when setup and product behavior share the same transaction. For separately committed API or service tests, isolate data by database, schema, tenant, or run marker and perform exact dependency-aware cleanup.

Should database tests truncate tables after every test?

Usually not. Truncate is broad and can interfere with shared or parallel tests. Reserve it for a dedicated disposable database with strong environment guards and no other owners.

Why do PostgreSQL IDs have gaps after rollback?

Sequence and identity allocation is not generally reversed with the row transaction, and failed or parallel inserts can consume values. Tests should capture returned IDs and never require consecutive numbers.

How do you clean test rows with foreign keys?

Delete exact owned child and join rows before parents inside a cleanup transaction, unless verified cascade behavior is part of the design. Record every created ID so partial setup can still be cleaned.

Can direct SQL be used to set up API tests?

Yes, when bypassing application behavior is intentional and caches or projections are handled. Use the public API when validation, encryption, event publication, or derived data is part of the precondition.

How do you isolate parallel database tests?

Give each worker its own database, schema, tenant, or row-level run marker and generate unique business keys. Ensure each worker cleans only its owned namespace and cannot drop another worker's resources.

How can a test suite detect leaked database data?

Tag owned records with a run identifier and query for remaining rows at suite end. Report exact tables and counts, then clean through a guarded janitor or destroy the disposable database.

Related Guides