# Rip on DuckDB — Foreign Key Constraints
> **What works, what doesn't, and how the Rip schema runtime keeps the rough edges off.**
DuckDB is Rip's primary backing database — used by `@rip-lang/db`, the
`schema :model` ORM, the migration tool, and the various sample apps.
Most of DuckDB's behavior matches what you'd expect from any SQL engine.
**Foreign-key constraints are the one place where DuckDB diverges
meaningfully from PostgreSQL/SQLite/MySQL.** This doc is the canonical
explanation of that divergence — what works, what doesn't, why, and
the design patterns Rip uses to keep your application code clean.
If you're hitting a `Constraint Error: Violates foreign key
constraint because key "X: N" is still referenced by a foreign key
in a different table` and trying to figure out what's going on,
you're in the right place.
---
# Contents
1. [The exact rule](#1-the-exact-rule)
2. [What works (probably almost everything you'll write)](#2-what-works)
3. [What doesn't (the narrow forbidden case)](#3-what-doesnt)
4. [How Rip's schema runtime keeps you out of trouble](#4-how-rips-schema-runtime-keeps-you-out-of-trouble)
5. [Worked example — patient records with orders](#5-worked-example)
6. [When you genuinely need to mutate a referenced indexed column](#6-when-you-genuinely-need-to-mutate)
7. [Escape hatches](#7-escape-hatches)
8. [Should I use DuckDB for OLTP at all?](#8-should-i-use-duckdb-for-oltp-at-all)
9. [Transactions, sequences, and migration edges](#9-transactions-sequences-and-migration-edges)
---
## 1. The exact rule
DuckDB rejects an UPDATE statement when **all three** conditions hold:
1. There is at least one row in another table whose foreign key
references the row being updated.
2. The UPDATE statement's `SET` clause touches at least one column
that participates in an **index** — `PRIMARY KEY` or `UNIQUE`.
3. The new value differs from the old value (DuckDB internally
re-keys via a delete-then-insert cycle on the index).
If any of those three is false, the UPDATE succeeds.
The same rule applies to DELETE: DuckDB rejects DELETE on a row that
is currently referenced by another table's FK.
This is documented at under
"Foreign Keys" and is a deliberate design choice: DuckDB's index
maintenance and FK enforcement are tightly coupled, and supporting
in-place rewrites of indexed parent rows would require significant
internal restructuring.
The behavior is **stable across DuckDB versions** — at the time of
writing (DuckDB v1.5.2) and consistent back through several minor
releases.
---
## 2. What works
The narrow forbidden case is so narrow that nearly every operation
you'll write passes through fine. The decisive table:
| Operation | Status | Notes |
|---|---|---|
| INSERT into the parent table | works | |
| INSERT into the child table | works | The standard FK existence check on the parent runs as expected. |
| SELECT, JOIN, aggregate, anything read-only | works | |
| UPDATE non-indexed columns of an unreferenced parent | works | |
| UPDATE non-indexed columns of a *referenced* parent | **works** | This is the one that surprises people most. Demographics, status fields, body text — all fine. |
| UPDATE indexed columns of an *unreferenced* parent | works | |
| UPDATE child-table columns (any) | works | Only the parent side has the restriction. |
| DELETE child rows | works | |
| DELETE parent row that has no current children | works | |
| Multi-row UPDATE that doesn't change indexed values | works | |
The narrow case to watch:
| Operation | Status |
|---|---|
| UPDATE indexed column on a referenced parent, value actually changes | **rejected** |
| DELETE referenced parent row | **rejected** |
That's it. Two cases. Both rejected. Everything else works.
---
## 3. What doesn't
The forbidden operations are:
```sql
-- Suppose orders.patient_id REFERENCES patients(id), and there's
-- at least one orders row with patient_id = 42.
UPDATE patients SET id = 999 WHERE id = 42; -- rejected
UPDATE patients SET mrn = 'NEW-X' WHERE id = 42; -- rejected (mrn is in UNIQUE)
UPDATE patients SET partner_id = 7 WHERE id = 42; -- rejected (partner_id is in UNIQUE)
DELETE FROM patients WHERE id = 42; -- rejected
UPDATE patients SET first_name = 'Steve' WHERE id = 42; -- works
UPDATE patients SET phone = '...' WHERE id = 42; -- works
UPDATE patients SET mdm_id = 'L00X' WHERE id = 42; -- works
UPDATE patients SET link_id = 'PID9' WHERE id = 42; -- works
```
The error message DuckDB returns names the FK column on the *child*
side, not the indexed column on the *parent* side. That's confusing
the first time you see it:
```
Constraint Error: Violates foreign key constraint because key
"patient_id: 42" is still referenced by a foreign key in a different
table. If this is an unexpected constraint violation, please refer
to our foreign key limitations in the documentation
```
The phrase "patient_id: 42" means: the FK column in the child table
is `orders.patient_id`, and the value `42` is what's being referenced.
The actual offending column on the parent (the one that's in the
index DuckDB is trying to re-key) isn't named in the error.
---
## 4. How Rip's schema runtime keeps you out of trouble
The Rip schema runtime turns full-row updates into
**column-targeted** updates. Most application code that "just calls
`save!`" never trips the rule.
When you do:
```coffee
patient = Patient.find! 42
patient.phone = '801-555-9999'
patient.save!
```
an unaware ORM might emit something like:
```sql
UPDATE patients
SET mrn=?, first_name=?, last_name=?, dob=?, sex=?, phone=?, email=?, mdm_id=?, link_id=?
WHERE id=?
```
That's a full-row UPDATE that touches `mrn` (an indexed column). On a
patient with existing orders, DuckDB rejects it — even though the
*value* of `mrn` isn't changing.
Rip's runtime instead emits:
```sql
UPDATE patients SET phone=? WHERE id=?
```
because the snapshot captured at `find!` time tells `save!` exactly
which columns the application actually mutated. No-op writes to other
columns are skipped, and the UPDATE never touches an indexed column
unless you genuinely changed its value. See the
[`save()` semantics section in RIP-SCHEMA.md](./RIP-SCHEMA.md#what-save-actually-writes)
for the full story.
The practical consequence: **you generally don't have to think about
this at all when writing Rip code**. The runtime takes care of it for
the 99% case. You only feel the limitation when your application
deliberately tries to change `id` / `mrn` / `partner_id` / a
`@belongs_to` FK on a row that already has children — and that's
usually a domain-model question, not a tech question.
---
## 5. Worked example
A small healthcare-style schema, exactly the medlabs case the rule
was originally documented from:
```coffee
Partner = schema :model
name! string
slug! string @unique
Patient = schema :model
mrn? string
firstName! string
lastName! string
dob? date
phone? phone
email? email
mdmId? string
linkId? string
@belongs_to Partner
@timestamps
@unique [:partnerId, :mrn]
Order = schema :model
status "draft" | "submitted" | "completed" | "cancelled", [:draft]
totalPrice integer, [0]
notes? text
@belongs_to Patient
@belongs_to Partner?
@timestamps
```
Indexed columns on `Patient`:
- `id` — surrogate PK, auto-managed
- `partner_id` — `@belongs_to` FK
- `(partner_id, mrn)` — composite UNIQUE
So the indexed-column set on `Patient` is `{id, partner_id, mrn}`.
### Operations that work, no thought required
```coffee
# INSERT — always works
patient = Patient.create!
partnerId: ola.id
mrn: 'MRN-00421'
firstName: 'Larry'
lastName: 'Jones'
# UPDATE non-indexed — always works
patient.phone = '(801) 555-0142'
patient.email = 'mjones@email.com'
patient.save! # writes only phone, email; Order references stay valid
# Sync an external identity-system ID — works (mdm_id non-indexed)
patient.mdmId = '5801776951206141'
patient.save! # writes only mdm_id
# Update a child row — child-side FK changes are fine
order = Order.find! 7
order.status = 'completed'
order.notes = 'Patient picked up sample'
order.save! # writes only status, notes
# Soft-delete an unreferenced row
new_patient = Patient.create! partnerId: ola.id, ...
new_patient.destroy! # works — no children yet
```
### The one case that fails
```coffee
# Patient 10023 has at least one Order. We want to change their MRN.
patient = Patient.find! 10023
patient.mrn = 'MRN-NEW-99'
patient.save!
# => Constraint Error: Violates foreign key constraint because key
# "patient_id: 10023" is still referenced by a foreign key in a
# different table.
```
This is the genuine restriction. DuckDB is telling you "you can't
rotate this patient's MRN while they have orders pointing at them."
The runtime *does* try this UPDATE — it correctly emits
`UPDATE patients SET mrn=? WHERE id=?` (no full-row write), and DuckDB
rejects it because `mrn` is in the `(partner_id, mrn)` UNIQUE index
and the row is referenced. The error is correctly Wikipedia-style
informative — it points at the right concept.
### How to handle it at the application layer
Three sensible patterns, in roughly increasing complexity:
**(a) Treat the indexed column as immutable** after first reference.
Surface a 409 / 422 from the API:
```coffee
post '/patients/:id/mrn' ->
user = userScope!
patient = Patient.find! @params.id
newMrn = read 'mrn', 'string!'
if Order.where(patientId: patient.id).count! > 0
error! 'Cannot change MRN after first order; create new patient instead', 409,
code: 'mrn_locked'
patient.mrn = newMrn
patient.save!
patient.toPublic()
```
This is what most healthcare systems actually do — MRN is identity,
and identity rotates only via a dedicated patient-merge workflow.
Cheap to implement, defensible domain rule.
**(b) Migrate the row.** INSERT a new patient with the new MRN,
repoint child rows, soft-delete the old one:
```coffee
oldPatient = Patient.find! id
newPatient = Patient.create! { ...oldPatient.toJSON(), id: undefined, mrn: newMrn }
sql! 'UPDATE orders SET patient_id = ? WHERE patient_id = ?', [newPatient.id, oldPatient.id]
oldPatient.destroy! # works after the UPDATE — old row is now unreferenced
```
The surrogate PK changes. Anything caching `patient.id` (a partner
holding it via API) breaks. Auditable, but operationally heavier.
**(c) Drop the FK constraint.** See [§7 escape hatches](#7-escape-hatches).
For most applications, **(a) is the right answer.** Codify the
"indexed columns are immutable after first reference" rule in your
domain model, and you stop fighting the database.
---
## 6. When you genuinely need to mutate
If your application's normal write patterns *require* mutating
indexed parent columns on referenced rows, that's a serious signal
about either the domain model or the choice of database.
Common cases that would hit this regularly:
| Pattern | Forbidden ops |
|---|---|
| Bulk-rename across joined tables ("lowercase all customer emails") | UPDATE on a UNIQUE column referenced by orders |
| Periodic merge of duplicate records | DELETE on a referenced row |
| Reassigning entities ("move all orders from user A to user B") | UPDATE on FK column of child OR DELETE old user |
| Auto-rotating UNIQUE business identifiers (e.g. account numbers) | UPDATE on UNIQUE column referenced elsewhere |
If your app does any of these *routinely*, you have two options:
1. Restructure so the indexed values are stable. (Often the right
call regardless — bulk-mutable identifiers are fragile no matter
what the database does.)
2. Use a different database. PostgreSQL and SQLite handle this
correctly and aren't going anywhere. See [§8](#8-should-i-use-duckdb-for-oltp-at-all).
If your app does these *occasionally* — once a quarter, in batch jobs,
under operator supervision — then [§7](#7-escape-hatches) is for you.
---
## 7. Escape hatches
### Drop the FK constraint
DuckDB without FKs is a fast columnar engine with no FK surprises.
Application-level FK enforcement (existence check before write,
optional cleanup jobs) replaces what the DB was doing.
The `@belongs_to` directive in Rip emits a `REFERENCES` clause in
DDL by default. To skip the constraint while keeping the relation
accessor and the camelCase/snake_case alias machinery, override the
DDL emission in your migration:
```sql
-- Generated by Rip, then hand-edit before running:
CREATE TABLE orders (
id INTEGER PRIMARY KEY DEFAULT nextval('orders_seq'),
patient_id INTEGER NOT NULL, -- was: REFERENCES patients(id)
...
);
```
You lose:
- DB-level guarantee that `orders.patient_id` always points at a
real patient
- DuckDB's check that you can't DELETE a referenced parent
You gain:
- Freedom to UPDATE / DELETE anything anytime
- Slightly faster writes (no constraint check)
For analytical-flavored apps, this is the right tradeoff. For
financial or healthcare records that must always be join-able, less
clearly so — but if your application code is rigorous, app-level
enforcement is fine. Most ORMs in the JavaScript ecosystem have
worked this way for years.
### Soft-rotate via INSERT + repoint + DELETE
Pattern (b) from [§5](#5-worked-example). Wrap it in a helper:
```coffee
export rotateMrn = (oldPatient, newMrn) ->
newPatient = Patient.create!
partnerId: oldPatient.partnerId
mrn: newMrn
firstName: oldPatient.firstName
lastName: oldPatient.lastName
dob: oldPatient.dob
phone: oldPatient.phone
email: oldPatient.email
mdmId: oldPatient.mdmId
linkId: oldPatient.linkId
sql! 'UPDATE orders WHERE patient_id = ? SET patient_id = ?', [oldPatient.id, newPatient.id]
oldPatient.destroy! # works — now unreferenced
newPatient
```
Surrogate ID changes; anything that cached the old ID externally
breaks. Acceptable for internal tools and scheduled migrations,
not for live API surfaces where partners hold the ID.
### Switch to a different database
If FK rigidity is repeatedly in the way:
- **SQLite** — file-based, no server, full FK semantics, well
supported, embedded in everything. Excellent for single-machine
apps. Limited write concurrency.
- **PostgreSQL** — server-based, full FK semantics including
`ON DELETE CASCADE` / `ON UPDATE CASCADE`, mature concurrency,
replication, point-in-time recovery, every feature you might
ever want. Operational overhead.
The Rip schema runtime is mostly DB-agnostic (the
`__schemaSetAdapter` seam) and the SQL it emits is portable for
the common operations. Swapping the underlying DB is a real
project but not a hostile one.
---
## 8. Should I use DuckDB for OLTP at all?
DuckDB is, by design, an **analytical** database. The marketing
puts it next to MotherDuck, Apache Arrow, columnar storage, and
"in-process analytics" benchmarks. Using it for transactional
workloads — high-volume row-level INSERT/UPDATE/DELETE with FK
relationships — is going against the canonical use case.
That said, DuckDB is *very good* at lots of things that look like
OLTP:
- INSERT-heavy workloads (orders, events, log entries)
- SELECT-heavy workloads (lookups, aggregations, reports)
- Single-host applications with moderate concurrency
- Apps where most rows are append-only and rarely mutated
The medlabs healthcare app fits this profile. Patients are mostly
inserted, occasionally updated (demographics, never identity), and
never deleted. Orders are inserted and have their `status` /
`notes` updated — both non-indexed columns. The
column-targeted UPDATE behavior of the Rip ORM keeps the
non-indexed-column update path open.
DuckDB is a poor fit when:
- Multiple writers on the same machine need fine-grained locking
(DuckDB has a single-writer model under the hood)
- Distributed write replication is required
- The workload mutates indexed business identifiers across joined
tables routinely
DuckDB is a fine fit when:
- The workload is mostly read, with append-heavy writes
- Indexed columns are surrogate keys or stable identifiers
- The schema favors denormalization or has shallow FK relations
- You want analytics queries to live in the same engine as the
transactional data
### Turning a mutation-shaped workload into an append-shaped one
A workload that looks write-heavy on paper is often append-heavy in
disguise. The standard move — shared by change feeds, event logs,
audit trails, and sync protocols (Replicache / Zero-style) — is an
**append-only log with a monotonic version column** beside the entity
tables: a write appends a new row instead of mutating an indexed
parent in place, and readers pull `WHERE version > :cursor`. This
plays directly to DuckDB's strengths (append-heavy writes, fast
monotonic range scans) and routes around the one real weak spot from
§1 — in-place updates to indexed / FK rows, which an append never
performs. The trade is the usual one for log-structured designs: the
log grows and wants periodic compaction (prune history below the
lowest live reader cursor, `CHECKPOINT` to flush), and "current state"
becomes a view or materialization over the log rather than the rows
themselves. When a write-heavy feature can be expressed this way, the
"poor fit" case above (routinely mutating indexed identifiers) often
stops applying — and harbor's single-port `/sql` with NDJSON streaming
is a natural transport for serving the resulting feed.
For a project where you're not sure: prototype with DuckDB, watch
the FK behavior, and switch to PostgreSQL or SQLite if you find
yourself reaching for the escape hatches more than once or twice.
---
## 9. Transactions, sequences, and migration edges
Findings from running the Rip Schema transaction and migration
machinery against live DuckDB (via duckdb-harbor) — each one shapes
runtime or differ behavior:
**FK-referenced tables are frozen for DDL too.** The §1 rule isn't
just about UPDATE/DELETE — most `ALTER TABLE` operations (add column,
drop column, type changes) on a table referenced by another table's FK
fail with `Dependency Error: Cannot alter entry "users" because there
are entries that depend on it`. Changing such a table means recreating
the referencing tables around it. The migration differ classifies
these steps as **`blocked`** in `rip schema status` and refuses to
write them into a migration file — the rebuild is a human decision.
Unreferenced tables alter normally.
**No `SAVEPOINT`.** DuckDB supports flat transactions only. A nested
`schema.transaction!` therefore *joins* the outer transaction rather
than creating an independently-rollbackable unit — one rollback undoes
the whole nest. Don't design flows that need partial rollback.
**Sequences are non-transactional.** `nextval()` consumed inside a
rolled-back transaction is not returned — a failed `create!` leaves a
gap in the `id` sequence. Gaps are normal and harmless; never write
code that assumes ids are contiguous, and never predict "the next id"
from the last one you saw.
**Harbor sessions work in every auth mode.** Transactions ride
duckdb-harbor's session protocol (`POST /sql/sessions/new`, then
per-statement `session_id`). Own-session lifecycle is scoped as
`__HARBOR_SELF__:sessions:create` / `:delete` — allowed by default for
any caller, including unauthenticated local-dev mode
(`harbor_serve(..., token := NULL)`), where sessions are owned by the
synthetic `harbor.local-dev` principal. A 403 from
`schema.transaction!` means a custom `harbor_authorization_function`
explicitly denies the `__HARBOR_SELF__:sessions:` scope — add a branch
matching it. (Earlier harbor versions misfiled session creation as an
admin action; that required `RIP_DB_TOKEN` plus an admin grant and is
the reason older notes here said transactions need a token.)
---
## See also
- [`docs/RIP-SCHEMA.md`](./RIP-SCHEMA.md) — the schema/ORM documentation,
including the `save()` semantics, dirty tracking, and `markDirty()`
escape hatch.
- [`packages/db/AGENTS.md`](../packages/db/AGENTS.md) — the
`@rip-lang/db` FFI client.
- [DuckDB Foreign Key documentation](https://duckdb.org/docs/sql/constraints) — upstream reference for the rule.