AWS Database Blog
Working with foreign key constraints in Aurora DSQL
Amazon Aurora DSQL supports foreign key constraints, so you can enforce application referential integrity directly in the database with enforcement behavior optimized for distributed, lock-free workloads.
In PostgreSQL, a foreign key check takes a FOR KEY SHARE lock on the referenced row, so a transaction that modifies the key columns or deletes the row waits behind any transaction still referencing it. Aurora DSQL checks foreign key constraints against storage snapshots as the transaction runs, then resolves concurrent conflicts at commit time without row-level blocking. This gives your application the referential integrity guarantees without sacrificing concurrency or throughput.
In this post, we walk through defining foreign key constraints in Aurora DSQL, configuring immediate versus deferred enforcement, adding constraints to existing tables without downtime, and understanding how optimistic concurrency control (OCC) resolves concurrent conflicts. We also cover designing foreign key (FK) relationships that scale safely, with practical examples including an ecommerce order pipeline and a data migration workflow, plus guidance on application-level retry logic for OCC conflicts.
Solution overview
Aurora DSQL implements foreign key constraints as distributed validation primitives. When a transaction modifies data that involves a foreign key relationship, Aurora DSQL performs snapshot-based validation at statement time (for immediate constraints) and final adjudication at commit time. A committed transaction can’t violate a foreign key constraint, and no transaction blocks another while these checks run.
Key features of Aurora DSQL foreign key support:
- PostgreSQL-compatible syntax: Standard REFERENCES and FOREIGN KEY clauses with support for all referential actions (NO ACTION, RESTRICT, CASCADE, SET NULL, SET DEFAULT), both match types (MATCH SIMPLE, the default, and MATCH FULL), and deferrable constraints.
- OCC-based conflict detection: No row-level blocking. Referential integrity is validated against snapshots at statement time, with conflicts resolved at commit time through optimistic concurrency control.
- Non-key update optimization: Aurora DSQL tracks referenced key columns through implicit KEY SHARE. Only key-column changes conflict with concurrent FK checks.
- Asynchronous ALTER TABLE: Add foreign keys to existing tables with
NOT VALIDand validate asynchronously usingALTER TABLE ASYNC ... VALIDATE CONSTRAINT, so you can evolve your schema with zero downtime.
Important: Cascading actions (CASCADE, SET NULL, SET DEFAULT) automatically modify rows in the referencing table when a referenced row is updated or deleted. The Aurora DSQL transaction row limit applies to these actions and can cause unexpected failures if not used carefully. Prefer NO ACTION or RESTRICT for foreign key relationships where child-row cardinality is unbounded or unpredictable. For more information, see Database limits in Aurora DSQL in the Aurora DSQL User Guide.
Prerequisites
You must have the following prerequisites to follow along with this post.
AWS account requirements:
- Active AWS account with an Aurora DSQL cluster running.
- Aurora DSQL requires all connections to use Transport Layer Security (TLS) encryption. To establish secure connections, your client system must trust the Amazon Root Certificate Authority (Amazon Root CA 1).
Required permissions:
- CREATE permission on schema for creating tables with foreign key constraints.
- ALTER permission for adding constraints to existing tables.
- DROP permission for removing constraints.
Tools and software needed:
- PostgreSQL-compatible client (psql, DBeaver, or application database driver).
- SQL client configured to connect to Aurora DSQL endpoint.
Create tables with foreign key constraints
You define foreign keys with standard PostgreSQL syntax, either inline on a column or as a table-level constraint.
Inline column constraint syntax
For single-column foreign keys, use the REFERENCES keyword directly on the column definition:
If you omit the column list, Aurora DSQL uses the primary key of the referenced table:
Named table-level constraint syntax
When you need composite foreign keys or deferrable constraints, use the CONSTRAINT … FOREIGN KEY syntax:
Composite foreign key example
Foreign keys can span multiple columns when referencing a composite primary key or unique constraint:
Self-referential foreign key
A foreign key constraint can reference the same table it belongs to. For example, representing a tree structure:
A top-level node would have NULL parent_id, while non-NULL parent_id entries are constrained to reference valid rows of the table.
Referential actions example
You can specify what happens when a referenced row is deleted or updated:
With RESTRICT, attempting to delete a product that has orders referencing it produces an error immediately. With the default NO ACTION, the check can be deferred to the end of the transaction if the constraint is declared DEFERRABLE.
Add foreign keys to existing tables
You can add foreign key constraints to existing tables using ALTER TABLE … ADD CONSTRAINT … NOT VALID. The NOT VALID option adds the constraint without validating existing data, which avoids scanning the entire table at data definition language (DDL) time.
After adding the constraint, new data manipulation language (DML) operations are validated against it. To validate existing data asynchronously without blocking writes:
You can also change the deferrability of an existing constraint:
Manage foreign key constraints
After constraints are in place, you can drop or inspect them.
Drop a constraint
To remove a foreign key constraint from a table:
Verify foreign key constraints
Query the system catalog to inspect foreign key definitions:
Immediate constraint enforcement
When a constraint is configured with immediate timing (the default), Aurora DSQL validates referential integrity at statement time by querying the transaction’s snapshot. Violations are rejected immediately.
Valid insert succeeds
Referencing a non-existent parent fails immediately
Deleting a referenced parent row fails
Updating a parent’s primary key to orphan children fails
Deleting unreferenced parent rows is allowed
Deferred constraint enforcement
The same constraint can be toggled to deferred enforcement within a transaction using SET CONSTRAINTS, if it was defined as deferrable in the first place. Deferring moves the validation check to commit time, so you can insert child rows before their parents exist, then satisfy the constraint before committing. This is useful during data loading or complex multi-statement transactions.
Note: In Aurora DSQL, SET CONSTRAINTS applies to foreign key constraints only. Primary key, unique, NOT NULL, and CHECK constraints are always checked immediately and can’t be deferred.
Insert referencing row before referenced row
Deferred violation at commit time
If the violation isn’t resolved before COMMIT, the entire transaction is rolled back:
Defer all foreign key constraints at once
SET CONSTRAINTS ALL DEFERRED defers every deferrable foreign key constraint in the transaction:
Switch back to immediate mid-transaction
Flipping a constraint back to IMMEDIATE within a transaction forces a snapshot validation check. Aurora DSQL immediately checks any outstanding data modifications that would otherwise have been deferred:
Concurrency and OCC behavior
Aurora DSQL uses OCC, a lock-free concurrency mechanism that eliminates the row-level blocking, deadlocks, and lock-wait timeouts common in traditional PostgreSQL deployments. Instead of acquiring locks when reading or writing rows, each transaction operates against a consistent snapshot of the database taken at its start time. Multiple transactions can read and write the same rows concurrently without waiting on each other. Only at commit time does the Aurora DSQL adjudicator evaluate whether any two concurrent transactions touched the same data in a conflicting way. If a conflict is found, one transaction succeeds and the other fails with a serialization error (SQLSTATE 40001), which the application can then retry.
Aurora DSQL maintains referential integrity in two steps:
- Snapshot validation: Every transaction runs against a consistent snapshot taken at its start time. When you insert a referencing row, Aurora DSQL reads the referenced table at that snapshot to confirm the referenced key exists. When you delete or update a referenced key, Aurora DSQL reads the referencing table at that snapshot. This confirms that no referencing rows exist for RESTRICT, or that the operation leaves no orphaned rows for NO ACTION.
- Commit-time conflict resolution: Snapshot validation proves that the constraint held at the transaction’s start time, but a concurrent transaction could have changed the referenced data since then. At commit time, Aurora DSQL detects whether any concurrent transaction affected your transaction’s referential integrity and fails the transaction with an OCC error if a conflict is found.
Important: All DML on referenced or referencing tables incurs extra reads to maintain referential integrity. Before adding a foreign key constraint to a table, make sure you benchmark the workload and validate the performance characteristics are in line with expectations.
Concurrent DELETE parent and INSERT child: Conflict
Both sessions run at the same time. Aurora DSQL resolves the conflict at commit. You can’t end up with an order that points to a deleted product.
Concurrent UPDATE parent PK and INSERT child: Conflict
Why non-key column updates don’t conflict
To adjudicate conflicts, Aurora DSQL implicitly applies KEY SHARE tracking to referenced rows. Only changes to key columns (columns in a PRIMARY KEY or members of a UNIQUE, non-partial, non-expression index) invalidate a concurrent foreign key check. Updates to non-key columns, like adjusting a product’s price, proceed without conflicting with new child inserts for that product.
Non-key column update (price): no conflict:
Key column update (name with UNIQUE index): conflict:
Note that name conflicts here because the products table declares it as TEXT UNIQUE, making it a key column.
Multiple child inserts on the same parent: No false conflicts
Two concurrent transactions inserting child rows that reference the same parent don’t conflict with each other:
Popular parent rows (like a best-selling product) can receive thousands of concurrent child inserts without any of them conflicting.
Designing for scale
Note: The examples from here on use a UUID-based schema that is independent of the tables created in earlier sections. If you want to run them, drop the earlier tables first or use a fresh session.
In traditional databases, unbounded One-to-Many (1:M) foreign key relationships risk table locks, massive index growth, and slow deletes. In Aurora DSQL, the lock risk is eliminated by OCC. But the performance and cost risks remain, and become even more critical when CASCADE actions are involved, because Aurora DSQL enforces a 3,000-row transaction mutation limit.
Before adding a foreign key, think about how the relationship will scale over time.
Scale-resistant relationships
These relationships are naturally capped and won’t cause problems at scale:
- Strict One-to-One (1:1): A child table references a parent through a UNIQUE foreign key. The child can never grow larger than the parent. (for example, users → user_profiles using user_id).
- Many-to-One lookups: Millions of rows reference a static or slow-growing configuration table. The “Many” side drives growth, keeping the parent capped. (for example, millions of users referencing a small countries table). Every child insert validates against the parent row. Updates to non-key columns on the parent (such as a country’s display_name) proceed without conflict. However, updates to the parent’s key columns (such as the referenced country_id) will conflict with any concurrent child insert, making key columns on heavily referenced lookup rows effectively immutable. This is typically the behavior that you want: lookup table keys should be stable, and any mutable attributes should live in non-key columns.
- Bounded One-to-Many: The child table can have multiple rows per parent, but real-world constraints keep the count small. (for example, orders → order_items. A typical shopping cart has a few dozen items). CASCADE is safe here because deletion affects a bounded number of children.
Scale-explosion risks
These are the relationships that cause trouble. The child side is unbounded. A single parent row can accumulate millions of children over time:
- The global “catch-all” anchor: Linking a massive stream of logging, audit, or event data directly to a single tenant or user. (for example, a high-activity corporate account anchoring billions of rows in an audit_logs table). In Aurora DSQL, a CASCADE delete on this parent would exceed the transaction row limit and fail.
- Viral engagement features: User activity linked directly to a single piece of content. A single viral post can accumulate millions of rows in post_likes, and a CASCADE delete on it would exceed the transaction row limit. (for example, a single popular row in posts suddenly anchoring 10,000,000 rows in post_likes). Every child insert triggers an FK validation read against that single parent row. At high volume, this becomes a measurable cost.
- High-volume product sales: In ecommerce, the products → order_items side is highly explosive. A single popular product (or a universal “Shipping Fee” item) can anchor millions of historical order rows. A CASCADE delete would attempt to remove every historical order line for that product, and fail.
Aurora DSQL-specific impact: In Aurora DSQL, unbounded FK relationships incur extra read costs on every child insert and parent row update for FK validation, and OCC conflicts if the parent’s key columns change while children are being inserted concurrently.
Architectural safeguards for Aurora DSQL
When a relationship might grow unbounded, apply these safeguards:
1. Never use CASCADE on unbounded keys. Avoid ON DELETE CASCADE on high-volume foreign keys (like product_id or tenant_id). In Aurora DSQL, deleting a parent with thousands of children exceeds the transaction row limit and fails. Use soft-deletes (is_active = false) instead, and clean up children in batched background transactions.
2. Snapshot mutable attributes at write time. For transaction-based data like orders, copy attributes that change over time (such as price and product_name) directly into the child table when the transaction runs. This preserves what the customer actually paid and what the product was called at order time, without relying on the parent table’s current state. Stable identifiers like sku that never change belong only in the parent table. The FK reference is sufficient.
3. Enforce pagination and time-bounds on queries. Never allow open-ended queries like SELECT * FROM order_items WHERE product_id = X. Force strict time-bounded or cursor-based filters. Using a consistent data type on both sides of a timestamp comparison helps the query planner use indexes effectively:
4. Use CHECK constraints for fixed enumerations instead of FK lookups. Low-cardinality tables (status codes, types, categories) add FK validation overhead on every insert with no real benefit. The values never change:
Practical examples
The preceding sections cover syntax and behavior. These scenarios show how the pieces fit together in production.
Ecommerce order pipeline
Schema with scale-aware FK design:
This schema applies the safeguards from “Designing for scale” earlier: CASCADE only on the bounded relationship (orders → order_lines), RESTRICT on the unbounded one (products → order_lines), and snapshotted product data at order time.
Placing an order with deferred constraints:
The fk_order constraint is DEFERRABLE INITIALLY DEFERRED, so we can insert order lines before the parent order row. We also snapshot the product name and price at order time:
Data migration with Aurora DSQL Loader
If you’re migrating data from another database, the Aurora DSQL Loader is the recommended approach. It’s an open source CLI that handles parallelization, connection pooling, conflict and retry handling, and AWS Identity and Access Management (IAM) authentication. Aurora DSQL also caps a write transaction at 10 MiB of modified data and 5 minutes of runtime. The Loader batches within all three limits automatically.
Step 1: Export data from the source database:
Step 2: Load parent tables first, then child tables:
Alternative: Load without FKs, then add constraints after:
To reduce load time, skip FK constraints during loading and add them afterward:
This two-phase approach avoids FK overhead during ingestion, so the initial load runs faster, and referential integrity is validated before you go live.
Application retry logic for OCC conflicts
Because Aurora DSQL uses OCC, conflicting transactions fail at commit time with SQLSTATE 40001 rather than waiting on locks. Your application needs to handle this. Aurora DSQL distinguishes two conflict types:
OC000(Data conflict): Two transactions modified the same row. Retry the entire transaction.OC001(Schema conflict): The session’s cached schema is stale (for example, after a concurrent DDL). Retry typically succeeds after one attempt as the session refreshes its catalog cache.
Key principles for Aurora DSQL retry logic:
- Always retry the entire transaction: After a
40001error, the transaction is rolled back. Re-execute from BEGIN. - Use exponential backoff with jitter: Adding randomness to the wait between retries spreads them out over time, reducing the chance that the same transactions collide again on the next attempt.
- Know which errors to retry: If you get
SQLSTATE 40001, that’s an OCC conflict, so retry the transaction. Schema conflicts (OC001) typically clear up on the first retry as the session picks up the latest catalog. But if you see a foreign key violation (23503), that’s a real data problem, not a transient conflict. Retrying won’t fix it. Check your application logic instead. - Cap retries and monitor the rate: Set a retry limit appropriate for your workload. If a transaction still fails after that, the issue is likely a hot parent row under too much concurrent write pressure, not a transient conflict. Track your
40001error rate in production. A sustained increase points to a schema that needs restructuring (see “Designing for scale” earlier).
Clean up
To remove the resources created in this post, run:
Conclusion
With foreign key constraints in Aurora DSQL, you can enforce referential integrity at the database level rather than in application code. Because Aurora DSQL uses OCC instead of row-level locking, concurrent transactions proceed without blocking each other, conflicts are caught at commit time, and non-key column updates never interfere with foreign key checks. With deferred enforcement, asynchronous constraint validation, and the scale-design patterns covered in this post, you can adopt foreign keys confidently in high-throughput, distributed workloads. To learn more, see the Concurrency control page in the Aurora DSQL User Guide.