skip to content

A view aggregates and joins several tables, so the engine will not accept writes against it. How does an INSTEAD OF trigger let you support writes anyway, and what are the risks of that approach?

level: seniorimportance: should knowfreq 35%

answer

  1. engine runs your code instead of the rewrite
  2. old/new row images → explicit base-table DML
  3. split insert, soft delete, legacy facade
  4. usually fires per row → bulk pain
  5. check option doesn't police the trigger

basics

~20 s

An INSTEAD OF trigger replaces the engine's automatic rewrite: instead of executing the INSERT, UPDATE or DELETE, the engine runs your code, which issues explicit statements against the base tables. Risks: you own correctness, multi-row semantics, ordering, error handling, and returned row counts.

solid answer

~60 s

When a view is not auto-updatable, the engine has no way to infer which base rows a write should touch. An INSTEAD OF trigger supplies that mapping in code: the engine fires the trigger *in place of* the DML, hands it the proposed old and new row images, and the trigger issues explicit statements against the base tables — inserting a parent then a child, updating only the columns that belong to a given table, or turning a delete into a status flip. What you take on is real. The trigger must handle multi-row statements correctly, not just single rows; in row-level implementations it fires per row, so a 100,000-row update means 100,000 trigger executions and usually a large performance regression. Constraint violations and ordering between tables are your problem. WITH CHECK OPTION generally does not police what the trigger does, so validating that the written row still belongs in the view is also on you. And the logic is invisible to anyone reading the application code — a plain INSERT now runs arbitrary server-side code.

code

sql · 10 lines
sql
CREATE TRIGGER customer_profile_ins
INSTEAD OF INSERT ON customer_profile
FOR EACH ROW
BEGIN
  INSERT INTO customers (customer_id, name)
  VALUES (NEW.customer_id, NEW.name);

  INSERT INTO addresses (customer_id, city, country)
  VALUES (NEW.customer_id, NEW.city, NEW.country);
END;

go deeper

for a junior

Know it exists as the escape hatch: the database runs your code instead of the write, and that code changes the real tables.

for a middle

Describe the mechanism with old/new row images and give a concrete use such as splitting an insert across two tables or converting a delete into a soft delete.

for a senior

Lead with the risks you would raise in review: per-row bulk cost, multi-row correctness, affected-row counts breaking ORMs, no check-option enforcement, and hidden behaviour at the call site.

for a principal

Decide where the write mapping belongs. Server-side triggers are justified mainly when the write surface must stay stable for callers you cannot redeploy; otherwise keep the logic in the application and the view read-only.

## What the trigger replaces For an auto-updatable view, the engine rewrites the incoming statement into an equivalent statement on the base table. When the view has joins, aggregates, unions or expressions, that rewrite does not exist. An INSTEAD OF trigger fills the gap: it is attached to the view, and when DML arrives, the engine runs the trigger body *instead of* attempting any write of its own. If the trigger body does nothing, nothing happens and the statement still reports success — the write is entirely whatever the trigger chooses to do. The trigger sees the proposed rows. For an INSERT it gets the new row image as the caller supplied it (columns the view does not expose are simply absent); for an UPDATE it gets old and new images; for a DELETE, the old image. From those it decides which base tables to modify. ## What it makes possible - **Multi-table writes.** A view joining customer and address can accept one INSERT and split it into two inserts in the right order, propagating the generated customer id into the address row. - **Column mapping.** A view exposing full_name can split it into first and last name columns on write. - **Soft deletes.** A DELETE through a view becomes an UPDATE setting deleted_at, so history is preserved while callers use ordinary SQL. - **Legacy facades.** A view can present the shape an old application expects while the trigger writes into a restructured schema — a genuinely useful migration tool. - **Validation and defaulting.** Values the view hides can be supplied centrally. ## The risks, in order of how often they bite **Row-at-a-time performance.** Most implementations fire INSTEAD OF triggers per affected row. A bulk statement therefore runs the trigger body once per row, each executing its own statements. Bulk loads through such a view can be orders of magnitude slower than writing to base tables directly. Test with realistic batch sizes, not with single rows. **Multi-row and set semantics.** Trigger code written with one row in mind often breaks on multi-row statements — in engines where the trigger fires per statement with transition tables, forgetting to iterate is a silent data bug rather than an error. Also, the affected-row count the client receives may no longer reflect what actually changed, which quietly breaks ORMs and optimistic-locking checks that assert "exactly one row updated". **Bypassed constraint semantics.** The view's own WITH CHECK OPTION generally does not apply to what a trigger does, so a trigger can write rows the view cannot see. Base-table constraints still fire, but their errors surface from inside the trigger, often with a message that means nothing to the caller. **Ordering and partial failure.** Writing parent before child, resolving generated keys, and deciding what happens when the second statement fails are all your design decisions. The statement runs inside the caller's transaction, so a raised error rolls the whole thing back — usually the behaviour you want, but only if you actually raise instead of swallowing. **Invisibility.** The most important risk is organisational. An application executes what looks like a single-table INSERT; three tables change, an audit row appears, and a delete does not delete. Nothing at the call site says so. Debugging, code review and incident response all get harder, and the logic lives in a deployment unit that often has weaker review and test discipline than application code. **Interaction with other triggers.** INSTEAD OF logic on the view plus BEFORE/AFTER triggers on the base tables produces an execution order few people can recall correctly, and recursion is easy to introduce. ## When it is the right call Reach for it when the write surface must stay stable while the schema beneath moves — legacy client compatibility, a staged migration, or a genuinely useful multi-table facade owned by the data team. Reach for it when the alternative is duplicating the same mapping logic across several applications that you do not control. Avoid it when the application could simply write to the base tables. "The ORM only knows how to write to one table" is a weak reason to move business logic into the database permanently. Also avoid it on hot bulk paths, where the per-row cost dominates. If you do adopt it: keep the body small and deterministic, iterate correctly over multi-row input, validate the resulting row against the view's intent explicitly since the check option will not, raise clear errors rather than silently skipping rows, version the trigger with migrations, and cover it with tests that exercise multi-row statements and failure paths. ## Interview framing Explain the mechanism first — the engine runs your code in place of its rewrite — then give one concrete use (splitting an insert across two tables, or a soft delete), then the risks: per-row performance, multi-row correctness, affected-row counts, no check-option enforcement, and the invisibility of server-side behaviour to application authors. Finishing with "and often the right answer is to write to the base tables" reads as production judgement rather than enthusiasm for a feature.

  • Does WITH CHECK OPTION on the view still protect you once an INSTEAD OF trigger handles the writes?
    Generally no. The check option constrains the engine's own rewrite, and once a trigger takes over, what it writes is not validated against the view predicate. If rows must stay inside the view's scope, the trigger has to test that itself and raise an error, or the invariant has to live on the base table as a constraint or row-security policy.
  • An ORM using optimistic locking starts failing against a view with an INSTEAD OF trigger. What is a likely cause?
    The affected-row count the client receives no longer reflects the intended row. ORMs assert that exactly one row was updated to detect concurrent modification, but a trigger may update a different number of base rows, or none, while the statement still reports success. The fix is to make the trigger's effect match the expected count, or to stop routing versioned writes through the view.

saying these in an interview costs you the question

  • Claiming an INSTEAD OF trigger makes the view itself updatable by the engine's rules
  • Assuming the trigger fires once per statement in every engine
  • Ignoring that a trigger body doing nothing still reports a successful write
  • Expecting the view's WITH CHECK OPTION to validate rows the trigger writes
  • Treating it as a free abstraction with no bulk-write cost

context