<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="/feed.xml" rel="self" type="application/atom+xml" /><link href="/" rel="alternate" type="text/html" /><updated>2026-09-02T14:59:31+00:00</updated><id>/feed.xml</id><title type="html">David’s Blog</title><subtitle>Personal writing on databases, distributed systems, and software engineering. All opinions expressed here are my own and do not reflect those of my employer.</subtitle><entry><title type="html">Foreign Keys in DSQL</title><link href="/foreign-keys-in-dsql/" rel="alternate" type="text/html" title="Foreign Keys in DSQL" /><published>2026-09-01T16:00:00+00:00</published><updated>2026-09-01T16:00:00+00:00</updated><id>/foreign-keys-in-dsql</id><content type="html" xml:base="/foreign-keys-in-dsql/"><![CDATA[<p>If you’ve been following the Aurora DSQL story (Marc Bowes’ <a href="https://marc-bowes.com/dsql-circle-of-life.html">Circle of Life</a>, Marc Brooker’s <a href="https://brooker.co.za/blog/2024/12/05/inside-dsql-writes.html">Inside DSQL Writes</a>, <a href="https://arxiv.org/abs/2607.13276">the DSQL paper</a>), you know the basics of what DSQL offers. DSQL’s query processors execute transactions independently, adjudicators perform optimistic concurrency control at commit time, and committed transactions flow through the journal to storage.</p>

<p>This architecture gives DSQL its performance characteristics and reduces the burden on developers to manage their database, but it also raises a question: how do you enforce referential integrity in a system with no locks? Previously, we recommended that customers write these referential integrity checks themselves. As of August 27th, DSQL developers can now use foreign key constraints to maintain referential integrity. In this post I walk through how we implement this in our shared-nothing, highly scalable architecture.</p>

<h2 id="snapshot-verification">Snapshot verification</h2>

<p>Every transaction in DSQL runs against a consistent snapshot of the database taken at its start time. When you insert or delete a row, DSQL reads the referenced row(s) at your transaction’s consistent snapshot time to confirm referential integrity is not violated by the modification. In the schema below, inserting into <code class="language-plaintext highlighter-rouge">orders</code> triggers an existence check against the transaction’s snapshot.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">products</span> <span class="p">(</span>
    <span class="n">product_id</span> <span class="nb">integer</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
    <span class="n">name</span> <span class="nb">text</span><span class="p">,</span>
    <span class="n">price</span> <span class="nb">numeric</span>
<span class="p">);</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">orders</span> <span class="p">(</span>
    <span class="n">order_id</span> <span class="nb">integer</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
    <span class="n">product_id</span> <span class="nb">integer</span> <span class="k">REFERENCES</span> <span class="n">products</span><span class="p">,</span>
    <span class="n">quantity</span> <span class="nb">integer</span>
<span class="p">);</span>

<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">products</span> <span class="k">VALUES</span> <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="s1">'Widget'</span><span class="p">,</span> <span class="mi">9</span><span class="p">.</span><span class="mi">99</span><span class="p">);</span>

<span class="c1">-- Implicitly read `products` at the transaction's snapshot time</span>
<span class="c1">-- to confirm product_id = 1 exists.</span>
<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">orders</span> <span class="k">VALUES</span> <span class="p">(</span><span class="mi">100</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">5</span><span class="p">);</span>
</code></pre></div></div>

<p>This is fast and lock-free, but doesn’t answer the question of what happens when a concurrent transaction is modifying that row. Between your transaction’s start time and commit time, another transaction could delete the referenced row you depend on, or insert a reference to a row that you’re about to remove.</p>

<h2 id="adjudication">Adjudication</h2>

<p>DSQL’s adjudicators can express existence dependencies, which is how we ensure that foreign key constraints in DSQL properly maintain referential integrity. When a transaction inserts a referencing row, DSQL implicitly marks a <code class="language-plaintext highlighter-rouge">KEY SHARE</code> dependency on the referenced row. At commit time, the adjudicator checks if any concurrent transaction deleted that referenced row or modified its key columns between t-start and t-commit. If so, the adjudicator rejects the transaction with a serialization error. Let’s look at how this works in practice with two examples:</p>

<h3 id="conflict-concurrent-delete-and-insert">Conflict: concurrent delete and insert</h3>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Session A</span>
<span class="k">BEGIN</span><span class="p">;</span>
<span class="k">DELETE</span> <span class="k">FROM</span> <span class="n">products</span> <span class="k">WHERE</span> <span class="n">product_id</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>

<span class="c1">-- Session B</span>
<span class="k">BEGIN</span><span class="p">;</span>
<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">orders</span> <span class="k">VALUES</span> <span class="p">(</span><span class="mi">100</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">5</span><span class="p">);</span>

<span class="c1">-- Session A</span>
<span class="k">COMMIT</span><span class="p">;</span>  <span class="c1">-- succeeds</span>

<span class="c1">-- Session B</span>
<span class="k">COMMIT</span><span class="p">;</span>  <span class="c1">-- fails: OC000 serialization error</span>
</code></pre></div></div>

<p>The adjudicator detects the conflict correctly: Session B depends on <code class="language-plaintext highlighter-rouge">product_id</code> 1’s existence, and fails to commit since Session A deleted that product.</p>

<h3 id="no-conflict-non-key-column-update">No conflict: non-key column update</h3>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Session A</span>
<span class="k">BEGIN</span><span class="p">;</span>
<span class="k">UPDATE</span> <span class="n">products</span> <span class="k">SET</span> <span class="n">name</span> <span class="o">=</span> <span class="s1">'Super Widget'</span> <span class="k">WHERE</span> <span class="n">product_id</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>

<span class="c1">-- Session B</span>
<span class="k">BEGIN</span><span class="p">;</span>
<span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">orders</span> <span class="k">VALUES</span> <span class="p">(</span><span class="mi">101</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">3</span><span class="p">);</span>

<span class="c1">-- Session A</span>
<span class="k">COMMIT</span><span class="p">;</span>  <span class="c1">-- succeeds</span>

<span class="c1">-- Session B</span>
<span class="k">COMMIT</span><span class="p">;</span>  <span class="c1">-- succeeds</span>
</code></pre></div></div>

<p>Session A updated <code class="language-plaintext highlighter-rouge">name</code>, which is a non-key column, while Session B referenced <code class="language-plaintext highlighter-rouge">product_id</code>, a key column. The adjudicator sees no conflict and both of the transactions commit successfully.</p>

<h2 id="how-this-maps-to-postgresqls-behavior">How this maps to PostgreSQL’s behavior</h2>

<p>If you’re familiar with PostgreSQL’s row-level locking, you’ll recognize the concepts above. PostgreSQL defines <a href="https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS">four row-level lock modes</a>: <code class="language-plaintext highlighter-rouge">FOR UPDATE</code>, <code class="language-plaintext highlighter-rouge">FOR NO KEY UPDATE</code>, <code class="language-plaintext highlighter-rouge">FOR SHARE</code>, and <code class="language-plaintext highlighter-rouge">FOR KEY SHARE</code>, from most to least restrictive. In the context of foreign keys, the critical modes are the <code class="language-plaintext highlighter-rouge">KEY SHARE</code> and <code class="language-plaintext highlighter-rouge">NO KEY UPDATE</code> modes, as they are what allow concurrent existence checks and non-key updates to referenced rows to execute without conflicts. DSQL only exposes <code class="language-plaintext highlighter-rouge">FOR UPDATE</code> and <code class="language-plaintext highlighter-rouge">FOR KEY SHARE</code> as explicit clauses on <code class="language-plaintext highlighter-rouge">SELECT</code>s, but DML operations implicitly use the <code class="language-plaintext highlighter-rouge">NO KEY UPDATE</code> mechanism for non-key-column updates. DSQL respects this conflict matrix in its optimistic concurrency control protocol, allowing adjudication to properly identify conflicts between existence checks and other DML.</p>

<h2 id="to-cascade-or-not-to-cascade">To cascade or not to cascade</h2>

<p>DSQL supports all referential action types defined in the SQL standard: <code class="language-plaintext highlighter-rouge">NO ACTION</code>, <code class="language-plaintext highlighter-rouge">RESTRICT</code>, <code class="language-plaintext highlighter-rouge">CASCADE</code>, <code class="language-plaintext highlighter-rouge">SET NULL</code>, and <code class="language-plaintext highlighter-rouge">SET DEFAULT</code>. When you define a foreign key constraint with <code class="language-plaintext highlighter-rouge">ON DELETE CASCADE</code>, deleting a referenced row automatically deletes all referencing rows. With <code class="language-plaintext highlighter-rouge">SET NULL</code> or <code class="language-plaintext highlighter-rouge">SET DEFAULT</code>, the referencing rows’ foreign key columns are set to null or their default value instead.</p>

<p>Consider a <code class="language-plaintext highlighter-rouge">products</code> table with a million orders referencing a single popular product. If we want to get rid of that product, it would cause the executing transaction to scan the <code class="language-plaintext highlighter-rouge">orders</code> table and delete a million rows. In DSQL, this would fail because every transaction is subject to a modification limit of 3,000 rows.</p>

<p>DSQL’s architecture is optimized for transactions that do bounded amounts of work. Our recommendation is to perform cascading actions bottom-up. Instead of relying on <code class="language-plaintext highlighter-rouge">ON DELETE CASCADE</code>, delete the referencing rows first, then the referenced row:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- Use a CTE with a LIMIT clause to stay within the row limit</span>
<span class="k">WITH</span> <span class="n">batch</span> <span class="k">AS</span> <span class="p">(</span>
    <span class="k">SELECT</span> <span class="n">order_id</span> <span class="k">FROM</span> <span class="n">orders</span> <span class="k">WHERE</span> <span class="n">product_id</span> <span class="o">=</span> <span class="mi">1</span> <span class="k">LIMIT</span> <span class="mi">3000</span>
<span class="p">)</span>
<span class="k">DELETE</span> <span class="k">FROM</span> <span class="n">orders</span> <span class="k">WHERE</span> <span class="n">order_id</span> <span class="k">IN</span> <span class="p">(</span><span class="k">SELECT</span> <span class="n">order_id</span> <span class="k">FROM</span> <span class="n">batch</span><span class="p">);</span>

<span class="c1">-- Repeat until no referencing rows remain, then delete the referenced row:</span>
<span class="k">DELETE</span> <span class="k">FROM</span> <span class="n">products</span> <span class="k">WHERE</span> <span class="n">product_id</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
</code></pre></div></div>

<p>This approach keeps each transaction within DSQL’s limits and makes the impact of the operation visible in your application rather than being hidden by a constraint’s action type.</p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>Foreign key constraints in DSQL leverage the adjudicator’s ability to express existence dependencies without introducing pessimistic locking. The same operations that would conflict under PostgreSQL’s row-level locking semantics conflict with DSQL’s optimistic concurrency control.</p>

<p>For more information on how foreign key constraints work in DSQL, check out the AWS documentation on <a href="https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-foreign-key-constraints.html">foreign key constraints</a> and <a href="https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-concurrency-control.html">concurrency control</a> in Aurora DSQL. For an interactive view of how DSQL transactions work, check out Marc Brooker’s tool <a href="https://brooker.co.za/dsql-transaction-flow.html#foreign-key">here</a>.</p>]]></content><author><name></name></author><category term="dsql" /><category term="databases" /><summary type="html"><![CDATA[If you’ve been following the Aurora DSQL story (Marc Bowes’ Circle of Life, Marc Brooker’s Inside DSQL Writes, the DSQL paper), you know the basics of what DSQL offers. DSQL’s query processors execute transactions independently, adjudicators perform optimistic concurrency control at commit time, and committed transactions flow through the journal to storage.]]></summary></entry></feed>