[{"data":1,"prerenderedAt":4015},["ShallowReactive",2],{"page-\u002Fsql\u002F21-triggers-and-events":3},{"id":4,"title":5,"body":6,"description":4008,"extension":4009,"meta":4010,"navigation":190,"path":4011,"seo":4012,"stem":4013,"__hash__":4014},"content\u002Fsql\u002F21-triggers-and-events.md","21 — Triggers & Events",{"type":7,"value":8,"toc":3988},"minimark",[9,13,41,46,277,281,352,378,382,525,529,626,630,758,762,1049,1053,1166,1184,1188,1348,1352,1359,1504,1508,1518,2236,2240,2500,2510,2514,2751,2755,3173,3177,3307,3311,3405,3409,3549,3553,3731,3735,3755,3859,3984],[10,11,5],"h1",{"id":12},"_21-triggers-events",[14,15,16,17,21,22,25,26,29,30,33,34,33,37,40],"p",{},"A trigger is two objects: a ",[18,19,20],"strong",{},"function"," (the code) and a ",[18,23,24],{},"trigger"," (the binding to a table + event + timing + granularity). The function returns ",[27,28,24],"code",{},"; the trigger decides when to call it. Every trigger fires invisibly — the application's ",[27,31,32],{},"INSERT","\u002F",[27,35,36],{},"UPDATE",[27,38,39],{},"DELETE"," statement doesn't reference them. That invisibility is their power and their danger.",[42,43,45],"h2",{"id":44},"trigger-anatomy-the-two-object-model","Trigger Anatomy — the two-object model",[47,48,50],"code-wrapper",{"language":49},"sql",[51,52,56],"pre",{"className":53,"code":54,"language":49,"meta":55,"style":55},"language-sql shiki shiki-themes github-light github-dark","-- ── Object 1: the trigger function ──────────────────────────────\n-- Returns `trigger` (a pseudo-type). Takes no args (use TG_ARGV for\n-- trigger-passed arguments). Lives in pg_proc like any function.\nCREATE FUNCTION touch_modified_at() RETURNS trigger AS $$\nBEGIN\n  -- BEFORE row triggers receive NEW (incoming row) and can mutate it.\n  -- Returning NEW proceeds; returning NULL silently drops the row.\n  NEW.modified_at = now();          -- mutate in place, then return\n  RETURN NEW;                       -- WITHOUT this, the row is dropped\nEND;\n$$ LANGUAGE plpgsql;\n\n-- ── Object 2: the trigger binding ───────────────────────────────\n-- Binds the function to (table, event, timing, granularity).\nCREATE TRIGGER touch_modified_at\n  BEFORE UPDATE ON customers        -- timing: BEFORE sees\u002Fmodifies NEW\n  FOR EACH ROW                       -- granularity: fires once per row\n  EXECUTE FUNCTION touch_modified_at();\n-- Now every UPDATE on customers silently sets modified_at = now().\n-- The application never sees this. Debugging \"why did modified_at\n-- change?\" requires hunting pg_trigger, not the app code.\n","",[27,57,58,67,73,79,109,115,121,127,152,164,173,185,192,198,204,215,233,248,259,265,271],{"__ignoreMap":55},[59,60,63],"span",{"class":61,"line":62},"line",1,[59,64,66],{"class":65},"sdCPZ","-- ── Object 1: the trigger function ──────────────────────────────\n",[59,68,70],{"class":61,"line":69},2,[59,71,72],{"class":65},"-- Returns `trigger` (a pseudo-type). Takes no args (use TG_ARGV for\n",[59,74,76],{"class":61,"line":75},3,[59,77,78],{"class":65},"-- trigger-passed arguments). Lives in pg_proc like any function.\n",[59,80,82,86,89,93,97,100,103,106],{"class":61,"line":81},4,[59,83,85],{"class":84},"svdQ7","CREATE",[59,87,88],{"class":84}," FUNCTION",[59,90,92],{"class":91},"sIsaT"," touch_modified_at",[59,94,96],{"class":95},"ssxIu","() ",[59,98,99],{"class":84},"RETURNS",[59,101,102],{"class":95}," trigger ",[59,104,105],{"class":84},"AS",[59,107,108],{"class":95}," $$\n",[59,110,112],{"class":61,"line":111},5,[59,113,114],{"class":84},"BEGIN\n",[59,116,118],{"class":61,"line":117},6,[59,119,120],{"class":65},"  -- BEFORE row triggers receive NEW (incoming row) and can mutate it.\n",[59,122,124],{"class":61,"line":123},7,[59,125,126],{"class":65},"  -- Returning NEW proceeds; returning NULL silently drops the row.\n",[59,128,130,134,137,140,143,146,149],{"class":61,"line":129},8,[59,131,133],{"class":132},"snvgF","  NEW",[59,135,136],{"class":95},".",[59,138,139],{"class":132},"modified_at",[59,141,142],{"class":84}," =",[59,144,145],{"class":84}," now",[59,147,148],{"class":95},"();          ",[59,150,151],{"class":65},"-- mutate in place, then return\n",[59,153,155,158,161],{"class":61,"line":154},9,[59,156,157],{"class":84},"  RETURN",[59,159,160],{"class":95}," NEW;                       ",[59,162,163],{"class":65},"-- WITHOUT this, the row is dropped\n",[59,165,167,170],{"class":61,"line":166},10,[59,168,169],{"class":84},"END",[59,171,172],{"class":95},";\n",[59,174,176,179,182],{"class":61,"line":175},11,[59,177,178],{"class":95},"$$ ",[59,180,181],{"class":84},"LANGUAGE",[59,183,184],{"class":95}," plpgsql;\n",[59,186,188],{"class":61,"line":187},12,[59,189,191],{"emptyLinePlaceholder":190},true,"\n",[59,193,195],{"class":61,"line":194},13,[59,196,197],{"class":65},"-- ── Object 2: the trigger binding ───────────────────────────────\n",[59,199,201],{"class":61,"line":200},14,[59,202,203],{"class":65},"-- Binds the function to (table, event, timing, granularity).\n",[59,205,207,209,212],{"class":61,"line":206},15,[59,208,85],{"class":84},[59,210,211],{"class":84}," TRIGGER",[59,213,214],{"class":91}," touch_modified_at\n",[59,216,218,221,224,227,230],{"class":61,"line":217},16,[59,219,220],{"class":84},"  BEFORE",[59,222,223],{"class":84}," UPDATE",[59,225,226],{"class":84}," ON",[59,228,229],{"class":95}," customers        ",[59,231,232],{"class":65},"-- timing: BEFORE sees\u002Fmodifies NEW\n",[59,234,236,239,242,245],{"class":61,"line":235},17,[59,237,238],{"class":84},"  FOR",[59,240,241],{"class":95}," EACH ",[59,243,244],{"class":84},"ROW",[59,246,247],{"class":65},"                       -- granularity: fires once per row\n",[59,249,251,254,256],{"class":61,"line":250},18,[59,252,253],{"class":84},"  EXECUTE",[59,255,88],{"class":84},[59,257,258],{"class":95}," touch_modified_at();\n",[59,260,262],{"class":61,"line":261},19,[59,263,264],{"class":65},"-- Now every UPDATE on customers silently sets modified_at = now().\n",[59,266,268],{"class":61,"line":267},20,[59,269,270],{"class":65},"-- The application never sees this. Debugging \"why did modified_at\n",[59,272,274],{"class":61,"line":273},21,[59,275,276],{"class":65},"-- change?\" requires hunting pg_trigger, not the app code.\n",[42,278,280],{"id":279},"before-vs-after-the-decision-matrix","BEFORE vs AFTER — the decision matrix",[282,283,284,306],"table",{},[285,286,287],"thead",{},[288,289,290,294,297,300,303],"tr",{},[291,292,293],"th",{},"Timing",[291,295,296],{},"Can modify row?",[291,298,299],{},"Sees other triggers' changes?",[291,301,302],{},"Row written yet?",[291,304,305],{},"Use case",[307,308,309,333],"tbody",{},[288,310,311,317,324,327,330],{},[312,313,314],"td",{},[27,315,316],{},"BEFORE",[312,318,319,320,323],{},"Yes (mutate ",[27,321,322],{},"NEW",", return it)",[312,325,326],{},"No (fires before write)",[312,328,329],{},"No",[312,331,332],{},"Validation, defaulting, transforming input, canceling the op",[288,334,335,340,343,346,349],{},[312,336,337],{},[27,338,339],{},"AFTER",[312,341,342],{},"No (row already written)",[312,344,345],{},"Yes (sees final state)",[312,347,348],{},"Yes",[312,350,351],{},"Audit logging, notifications, cascading to other tables",[14,353,354,356,357,361,362,364,365,368,369,356,371,374,375,377],{},[27,355,316],{}," fires ",[358,359,360],"em",{},"before"," the row hits the table — it can mutate ",[27,363,322],{}," and return it (the mutated version is what gets written). Returning ",[27,366,367],{},"NULL"," silently cancels the operation for that row (no error, no log). ",[27,370,339],{},[358,372,373],{},"after"," the write — ",[27,376,322],{}," is read-only, but the final committed state is visible and other triggers' modifications are reflected.",[42,379,381],{"id":380},"for-each-row-vs-for-each-statement","FOR EACH ROW vs FOR EACH STATEMENT",[47,383,384],{"language":49},[51,385,387],{"className":53,"code":386,"language":49,"meta":55,"style":55},"-- FOR EACH ROW: fires once per affected row. NEW\u002FOLD available.\n-- A 1M-row UPDATE fires the function 1,000,000 times.\nCREATE TRIGGER row_level_audit\n  AFTER UPDATE ON orders\n  FOR EACH ROW\n  EXECUTE FUNCTION audit_order_row();\n\n-- FOR EACH STATEMENT: fires once per statement, regardless of row count.\n-- NEW\u002FOLD NOT available — use transition tables (REFERENCING) to see\n-- the full set of changed rows. A 1M-row UPDATE fires the function ONCE.\nCREATE TRIGGER stmt_level_audit\n  AFTER UPDATE ON orders\n  REFERENCING NEW TABLE AS new_rows OLD TABLE AS old_rows\n  FOR EACH STATEMENT\n  EXECUTE FUNCTION audit_order_batch();\n-- new_rows \u002F old_rows are pseudo-tables containing ALL changed rows.\n-- Bulk-audit in a single INSERT...SELECT instead of 1M function calls.\n",[27,388,389,394,399,408,420,429,438,442,447,452,457,466,476,497,506,515,520],{"__ignoreMap":55},[59,390,391],{"class":61,"line":62},[59,392,393],{"class":65},"-- FOR EACH ROW: fires once per affected row. NEW\u002FOLD available.\n",[59,395,396],{"class":61,"line":69},[59,397,398],{"class":65},"-- A 1M-row UPDATE fires the function 1,000,000 times.\n",[59,400,401,403,405],{"class":61,"line":75},[59,402,85],{"class":84},[59,404,211],{"class":84},[59,406,407],{"class":91}," row_level_audit\n",[59,409,410,413,415,417],{"class":61,"line":81},[59,411,412],{"class":84},"  AFTER",[59,414,223],{"class":84},[59,416,226],{"class":84},[59,418,419],{"class":95}," orders\n",[59,421,422,424,426],{"class":61,"line":111},[59,423,238],{"class":84},[59,425,241],{"class":95},[59,427,428],{"class":84},"ROW\n",[59,430,431,433,435],{"class":61,"line":117},[59,432,253],{"class":84},[59,434,88],{"class":84},[59,436,437],{"class":95}," audit_order_row();\n",[59,439,440],{"class":61,"line":123},[59,441,191],{"emptyLinePlaceholder":190},[59,443,444],{"class":61,"line":129},[59,445,446],{"class":65},"-- FOR EACH STATEMENT: fires once per statement, regardless of row count.\n",[59,448,449],{"class":61,"line":154},[59,450,451],{"class":65},"-- NEW\u002FOLD NOT available — use transition tables (REFERENCING) to see\n",[59,453,454],{"class":61,"line":166},[59,455,456],{"class":65},"-- the full set of changed rows. A 1M-row UPDATE fires the function ONCE.\n",[59,458,459,461,463],{"class":61,"line":175},[59,460,85],{"class":84},[59,462,211],{"class":84},[59,464,465],{"class":91}," stmt_level_audit\n",[59,467,468,470,472,474],{"class":61,"line":187},[59,469,412],{"class":84},[59,471,223],{"class":84},[59,473,226],{"class":84},[59,475,419],{"class":95},[59,477,478,481,484,487,490,492,494],{"class":61,"line":194},[59,479,480],{"class":95},"  REFERENCING NEW ",[59,482,483],{"class":84},"TABLE",[59,485,486],{"class":84}," AS",[59,488,489],{"class":95}," new_rows OLD ",[59,491,483],{"class":84},[59,493,486],{"class":84},[59,495,496],{"class":95}," old_rows\n",[59,498,499,501,503],{"class":61,"line":200},[59,500,238],{"class":84},[59,502,241],{"class":95},[59,504,505],{"class":84},"STATEMENT\n",[59,507,508,510,512],{"class":61,"line":206},[59,509,253],{"class":84},[59,511,88],{"class":84},[59,513,514],{"class":95}," audit_order_batch();\n",[59,516,517],{"class":61,"line":217},[59,518,519],{"class":65},"-- new_rows \u002F old_rows are pseudo-tables containing ALL changed rows.\n",[59,521,522],{"class":61,"line":235},[59,523,524],{"class":65},"-- Bulk-audit in a single INSERT...SELECT instead of 1M function calls.\n",[42,526,528],{"id":527},"new-and-old-availability-by-event","NEW and OLD — availability by event",[282,530,531,552],{},[285,532,533],{},[288,534,535,538,542,547],{},[291,536,537],{},"Event",[291,539,540],{},[27,541,322],{},[291,543,544],{},[27,545,546],{},"OLD",[291,548,549,551],{},[27,550,316],{}," return semantics",[307,553,554,574,594,612],{},[288,555,556,560,563,565],{},[312,557,558],{},[27,559,32],{},[312,561,562],{},"the new row",[312,564,367],{},[312,566,567,568,570,571,573],{},"Return ",[27,569,322],{}," to proceed; ",[27,572,367],{}," to skip insert",[288,575,576,580,583,586],{},[312,577,578],{},[27,579,36],{},[312,581,582],{},"post-update row",[312,584,585],{},"pre-update row",[312,587,567,588,590,591,593],{},[27,589,322],{}," (modified) to proceed; ",[27,592,367],{}," to skip",[288,595,596,600,602,605],{},[312,597,598],{},[27,599,39],{},[312,601,367],{},[312,603,604],{},"the deleted row",[312,606,567,607,570,609,611],{},[27,608,546],{},[27,610,367],{}," to skip delete",[288,613,614,619,621,623],{},[312,615,616],{},[27,617,618],{},"TRUNCATE",[312,620,367],{},[312,622,367],{},[312,624,625],{},"Statement-level only; return value ignored",[42,627,629],{"id":628},"tg_-variables-the-triggers-runtime-context","TG_ Variables — the trigger's runtime context",[282,631,632,645],{},[285,633,634],{},[288,635,636,639,642],{},[291,637,638],{},"Variable",[291,640,641],{},"Value",[291,643,644],{},"Example",[307,646,647,673,688,703,721,738],{},[288,648,649,654,668],{},[312,650,651],{},[27,652,653],{},"TG_OP",[312,655,656,33,659,33,662,33,665],{},[27,657,658],{},"'INSERT'",[27,660,661],{},"'UPDATE'",[27,663,664],{},"'DELETE'",[27,666,667],{},"'TRUNCATE'",[312,669,670],{},[27,671,672],{},"IF TG_OP = 'DELETE' THEN ...",[288,674,675,680,683],{},[312,676,677],{},[27,678,679],{},"TG_TABLE_NAME",[312,681,682],{},"Table the trigger fired on",[312,684,685],{},[27,686,687],{},"'customers'",[288,689,690,695,698],{},[312,691,692],{},[27,693,694],{},"TG_TABLE_SCHEMA",[312,696,697],{},"Schema",[312,699,700],{},[27,701,702],{},"'public'",[288,704,705,710,718],{},[312,706,707],{},[27,708,709],{},"TG_WHEN",[312,711,712,33,715],{},[27,713,714],{},"'BEFORE'",[27,716,717],{},"'AFTER'",[312,719,720],{},"—",[288,722,723,728,736],{},[312,724,725],{},[27,726,727],{},"TG_LEVEL",[312,729,730,33,733],{},[27,731,732],{},"'ROW'",[27,734,735],{},"'STATEMENT'",[312,737,720],{},[288,739,740,749,752],{},[312,741,742,745,746],{},[27,743,744],{},"TG_NARGS"," \u002F ",[27,747,748],{},"TG_ARGV",[312,750,751],{},"Args passed to the trigger",[312,753,754,757],{},[27,755,756],{},"TG_ARGV[0]"," is the first arg",[42,759,761],{"id":760},"multi-event-triggers-with-tg_op-one-function-all-events","Multi-Event Triggers with TG_OP — one function, all events",[47,763,764],{"language":49},[51,765,767],{"className":53,"code":766,"language":49,"meta":55,"style":55},"-- A single trigger function handling INSERT\u002FUPDATE\u002FDELETE via TG_OP.\n-- This is the standard pattern for audit logs — one function, one trigger,\n-- all three DML events. TG_OP branches the logic.\nCREATE FUNCTION audit_generic() RETURNS trigger AS $$\nBEGIN\n  INSERT INTO audit_log (table_name, operation, row_id, old_data, new_data, changed_at, changed_by)\n  VALUES (\n    TG_TABLE_NAME,                                          -- 'customers'\n    TG_OP,                                                  -- 'INSERT' \u002F 'UPDATE' \u002F 'DELETE'\n    COALESCE(NEW.id, OLD.id),                               -- NEW.id for INSERT\u002FUPDATE, OLD.id for DELETE\n    CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN to_jsonb(OLD) END,  -- OLD only for UPDATE\u002FDELETE\n    CASE WHEN TG_OP IN ('INSERT','UPDATE') THEN to_jsonb(NEW) END,  -- NEW only for INSERT\u002FUPDATE\n    now(),                                                  -- statement timestamp (same for all rows in a stmt)\n    current_user                                            -- the DB role, not the app user\n  );\n  RETURN COALESCE(NEW, OLD);  -- AFTER trigger: return value ignored, but return something\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER customers_audit\n  AFTER INSERT OR UPDATE OR DELETE ON customers   -- one trigger, three events\n  FOR EACH ROW EXECUTE FUNCTION audit_generic();\n",[27,768,769,774,779,784,803,807,815,823,831,839,869,911,943,954,962,967,980,986,994,998,1007,1032],{"__ignoreMap":55},[59,770,771],{"class":61,"line":62},[59,772,773],{"class":65},"-- A single trigger function handling INSERT\u002FUPDATE\u002FDELETE via TG_OP.\n",[59,775,776],{"class":61,"line":69},[59,777,778],{"class":65},"-- This is the standard pattern for audit logs — one function, one trigger,\n",[59,780,781],{"class":61,"line":75},[59,782,783],{"class":65},"-- all three DML events. TG_OP branches the logic.\n",[59,785,786,788,790,793,795,797,799,801],{"class":61,"line":81},[59,787,85],{"class":84},[59,789,88],{"class":84},[59,791,792],{"class":91}," audit_generic",[59,794,96],{"class":95},[59,796,99],{"class":84},[59,798,102],{"class":95},[59,800,105],{"class":84},[59,802,108],{"class":95},[59,804,805],{"class":61,"line":111},[59,806,114],{"class":84},[59,808,809,812],{"class":61,"line":117},[59,810,811],{"class":84},"  INSERT INTO",[59,813,814],{"class":95}," audit_log (table_name, operation, row_id, old_data, new_data, changed_at, changed_by)\n",[59,816,817,820],{"class":61,"line":123},[59,818,819],{"class":84},"  VALUES",[59,821,822],{"class":95}," (\n",[59,824,825,828],{"class":61,"line":129},[59,826,827],{"class":95},"    TG_TABLE_NAME,                                          ",[59,829,830],{"class":65},"-- 'customers'\n",[59,832,833,836],{"class":61,"line":154},[59,834,835],{"class":95},"    TG_OP,                                                  ",[59,837,838],{"class":65},"-- 'INSERT' \u002F 'UPDATE' \u002F 'DELETE'\n",[59,840,841,844,847,849,851,854,857,859,861,863,866],{"class":61,"line":166},[59,842,843],{"class":132},"    COALESCE",[59,845,846],{"class":95},"(",[59,848,322],{"class":132},[59,850,136],{"class":95},[59,852,853],{"class":132},"id",[59,855,856],{"class":95},", ",[59,858,546],{"class":132},[59,860,136],{"class":95},[59,862,853],{"class":132},[59,864,865],{"class":95},"),                               ",[59,867,868],{"class":65},"-- NEW.id for INSERT\u002FUPDATE, OLD.id for DELETE\n",[59,870,871,874,877,880,883,886,889,892,894,897,900,903,905,908],{"class":61,"line":175},[59,872,873],{"class":84},"    CASE",[59,875,876],{"class":84}," WHEN",[59,878,879],{"class":95}," TG_OP ",[59,881,882],{"class":84},"IN",[59,884,885],{"class":95}," (",[59,887,661],{"class":888},"sJ6F3",[59,890,891],{"class":95},",",[59,893,664],{"class":888},[59,895,896],{"class":95},") ",[59,898,899],{"class":84},"THEN",[59,901,902],{"class":95}," to_jsonb(OLD) ",[59,904,169],{"class":84},[59,906,907],{"class":95},",  ",[59,909,910],{"class":65},"-- OLD only for UPDATE\u002FDELETE\n",[59,912,913,915,917,919,921,923,925,927,929,931,933,936,938,940],{"class":61,"line":187},[59,914,873],{"class":84},[59,916,876],{"class":84},[59,918,879],{"class":95},[59,920,882],{"class":84},[59,922,885],{"class":95},[59,924,658],{"class":888},[59,926,891],{"class":95},[59,928,661],{"class":888},[59,930,896],{"class":95},[59,932,899],{"class":84},[59,934,935],{"class":95}," to_jsonb(NEW) ",[59,937,169],{"class":84},[59,939,907],{"class":95},[59,941,942],{"class":65},"-- NEW only for INSERT\u002FUPDATE\n",[59,944,945,948,951],{"class":61,"line":194},[59,946,947],{"class":84},"    now",[59,949,950],{"class":95},"(),                                                  ",[59,952,953],{"class":65},"-- statement timestamp (same for all rows in a stmt)\n",[59,955,956,959],{"class":61,"line":200},[59,957,958],{"class":95},"    current_user                                            ",[59,960,961],{"class":65},"-- the DB role, not the app user\n",[59,963,964],{"class":61,"line":206},[59,965,966],{"class":95},"  );\n",[59,968,969,971,974,977],{"class":61,"line":217},[59,970,157],{"class":84},[59,972,973],{"class":132}," COALESCE",[59,975,976],{"class":95},"(NEW, OLD);  ",[59,978,979],{"class":65},"-- AFTER trigger: return value ignored, but return something\n",[59,981,982,984],{"class":61,"line":235},[59,983,169],{"class":84},[59,985,172],{"class":95},[59,987,988,990,992],{"class":61,"line":250},[59,989,178],{"class":95},[59,991,181],{"class":84},[59,993,184],{"class":95},[59,995,996],{"class":61,"line":261},[59,997,191],{"emptyLinePlaceholder":190},[59,999,1000,1002,1004],{"class":61,"line":267},[59,1001,85],{"class":84},[59,1003,211],{"class":84},[59,1005,1006],{"class":91}," customers_audit\n",[59,1008,1009,1011,1014,1017,1019,1021,1024,1026,1029],{"class":61,"line":273},[59,1010,412],{"class":84},[59,1012,1013],{"class":84}," INSERT",[59,1015,1016],{"class":84}," OR",[59,1018,223],{"class":84},[59,1020,1016],{"class":84},[59,1022,1023],{"class":84}," DELETE",[59,1025,226],{"class":84},[59,1027,1028],{"class":95}," customers   ",[59,1030,1031],{"class":65},"-- one trigger, three events\n",[59,1033,1035,1037,1039,1041,1044,1046],{"class":61,"line":1034},22,[59,1036,238],{"class":84},[59,1038,241],{"class":95},[59,1040,244],{"class":84},[59,1042,1043],{"class":84}," EXECUTE",[59,1045,88],{"class":84},[59,1047,1048],{"class":95}," audit_generic();\n",[42,1050,1052],{"id":1051},"when-clause-conditional-triggers-skip-the-function-call-entirely","WHEN Clause — conditional triggers (skip the function call entirely)",[47,1054,1055],{"language":49},[51,1056,1058],{"className":53,"code":1057,"language":49,"meta":55,"style":55},"-- WHEN is evaluated BEFORE the function runs. If false, the function\n-- is never called — zero overhead. This is a per-row filter, not a\n-- per-row function-internal check. For high-volume tables, a WHEN\n-- clause on the relevant column avoids millions of function calls\n-- on updates that don't touch that column.\nCREATE TRIGGER touch_modified_at\n  BEFORE UPDATE ON customers\n  FOR EACH ROW\n  WHEN (NEW.name IS DISTINCT FROM OLD.name)   -- IS DISTINCT FROM: NULL-safe inequality\n  EXECUTE FUNCTION touch_modified_at();\n-- An UPDATE that changes only `email` → WHEN is false → function never called.\n-- An UPDATE that sets name = name (no-op) → WHEN is false (DISTINCT FROM handles this).\n",[27,1059,1060,1065,1070,1075,1080,1085,1093,1104,1112,1148,1156,1161],{"__ignoreMap":55},[59,1061,1062],{"class":61,"line":62},[59,1063,1064],{"class":65},"-- WHEN is evaluated BEFORE the function runs. If false, the function\n",[59,1066,1067],{"class":61,"line":69},[59,1068,1069],{"class":65},"-- is never called — zero overhead. This is a per-row filter, not a\n",[59,1071,1072],{"class":61,"line":75},[59,1073,1074],{"class":65},"-- per-row function-internal check. For high-volume tables, a WHEN\n",[59,1076,1077],{"class":61,"line":81},[59,1078,1079],{"class":65},"-- clause on the relevant column avoids millions of function calls\n",[59,1081,1082],{"class":61,"line":111},[59,1083,1084],{"class":65},"-- on updates that don't touch that column.\n",[59,1086,1087,1089,1091],{"class":61,"line":117},[59,1088,85],{"class":84},[59,1090,211],{"class":84},[59,1092,214],{"class":91},[59,1094,1095,1097,1099,1101],{"class":61,"line":123},[59,1096,220],{"class":84},[59,1098,223],{"class":84},[59,1100,226],{"class":84},[59,1102,1103],{"class":95}," customers\n",[59,1105,1106,1108,1110],{"class":61,"line":129},[59,1107,238],{"class":84},[59,1109,241],{"class":95},[59,1111,428],{"class":84},[59,1113,1114,1117,1119,1121,1123,1126,1129,1132,1135,1138,1140,1142,1145],{"class":61,"line":154},[59,1115,1116],{"class":84},"  WHEN",[59,1118,885],{"class":95},[59,1120,322],{"class":132},[59,1122,136],{"class":95},[59,1124,1125],{"class":132},"name",[59,1127,1128],{"class":84}," IS",[59,1130,1131],{"class":84}," DISTINCT",[59,1133,1134],{"class":84}," FROM",[59,1136,1137],{"class":132}," OLD",[59,1139,136],{"class":95},[59,1141,1125],{"class":132},[59,1143,1144],{"class":95},")   ",[59,1146,1147],{"class":65},"-- IS DISTINCT FROM: NULL-safe inequality\n",[59,1149,1150,1152,1154],{"class":61,"line":166},[59,1151,253],{"class":84},[59,1153,88],{"class":84},[59,1155,258],{"class":95},[59,1157,1158],{"class":61,"line":175},[59,1159,1160],{"class":65},"-- An UPDATE that changes only `email` → WHEN is false → function never called.\n",[59,1162,1163],{"class":61,"line":187},[59,1164,1165],{"class":65},"-- An UPDATE that sets name = name (no-op) → WHEN is false (DISTINCT FROM handles this).\n",[14,1167,1168,1171,1172,856,1175,1178,1179,33,1181,1183],{},[18,1169,1170],{},"WHEN constraints",": no subqueries, no volatile functions (",[27,1173,1174],{},"random()",[27,1176,1177],{},"now()","), must be a simple expression on ",[27,1180,322],{},[27,1182,546],{},". For complex conditions, check inside the function.",[42,1185,1187],{"id":1186},"truncate-triggers-statement-level-only","TRUNCATE Triggers — statement-level only",[47,1189,1190],{"language":49},[51,1191,1193],{"className":53,"code":1192,"language":49,"meta":55,"style":55},"-- TRUNCATE has no row-level semantics (it doesn't fire per-row DELETE).\n-- FOR EACH STATEMENT is the only option. NEW\u002FOLD are NULL (no rows to\n-- reference). Useful for audit-logging truncates (which bypass DELETE\n-- triggers entirely — a common security blind spot).\nCREATE FUNCTION audit_truncate() RETURNS trigger AS $$\nBEGIN\n  INSERT INTO audit_log (table_name, operation, changed_at, changed_by)\n  VALUES (TG_TABLE_NAME, 'TRUNCATE', now(), current_user);\n  RETURN NULL;   -- statement-level: return value always ignored\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER customers_truncate_audit\n  AFTER TRUNCATE ON customers     -- TRUNCATE triggers are ALWAYS AFTER\n  FOR EACH STATEMENT              -- ALWAYS STATEMENT (no row-level)\n  EXECUTE FUNCTION audit_truncate();\n-- Without this, a TRUNCATE customers; wipes the table with ZERO audit\n-- trail — DELETE triggers don't fire on TRUNCATE. This is a common gap.\n",[27,1194,1195,1200,1205,1210,1215,1234,1238,1245,1262,1275,1281,1289,1293,1302,1317,1329,1338,1343],{"__ignoreMap":55},[59,1196,1197],{"class":61,"line":62},[59,1198,1199],{"class":65},"-- TRUNCATE has no row-level semantics (it doesn't fire per-row DELETE).\n",[59,1201,1202],{"class":61,"line":69},[59,1203,1204],{"class":65},"-- FOR EACH STATEMENT is the only option. NEW\u002FOLD are NULL (no rows to\n",[59,1206,1207],{"class":61,"line":75},[59,1208,1209],{"class":65},"-- reference). Useful for audit-logging truncates (which bypass DELETE\n",[59,1211,1212],{"class":61,"line":81},[59,1213,1214],{"class":65},"-- triggers entirely — a common security blind spot).\n",[59,1216,1217,1219,1221,1224,1226,1228,1230,1232],{"class":61,"line":111},[59,1218,85],{"class":84},[59,1220,88],{"class":84},[59,1222,1223],{"class":91}," audit_truncate",[59,1225,96],{"class":95},[59,1227,99],{"class":84},[59,1229,102],{"class":95},[59,1231,105],{"class":84},[59,1233,108],{"class":95},[59,1235,1236],{"class":61,"line":117},[59,1237,114],{"class":84},[59,1239,1240,1242],{"class":61,"line":123},[59,1241,811],{"class":84},[59,1243,1244],{"class":95}," audit_log (table_name, operation, changed_at, changed_by)\n",[59,1246,1247,1249,1252,1254,1256,1259],{"class":61,"line":129},[59,1248,819],{"class":84},[59,1250,1251],{"class":95}," (TG_TABLE_NAME, ",[59,1253,667],{"class":888},[59,1255,856],{"class":95},[59,1257,1258],{"class":84},"now",[59,1260,1261],{"class":95},"(), current_user);\n",[59,1263,1264,1266,1269,1272],{"class":61,"line":154},[59,1265,157],{"class":84},[59,1267,1268],{"class":84}," NULL",[59,1270,1271],{"class":95},";   ",[59,1273,1274],{"class":65},"-- statement-level: return value always ignored\n",[59,1276,1277,1279],{"class":61,"line":166},[59,1278,169],{"class":84},[59,1280,172],{"class":95},[59,1282,1283,1285,1287],{"class":61,"line":175},[59,1284,178],{"class":95},[59,1286,181],{"class":84},[59,1288,184],{"class":95},[59,1290,1291],{"class":61,"line":187},[59,1292,191],{"emptyLinePlaceholder":190},[59,1294,1295,1297,1299],{"class":61,"line":194},[59,1296,85],{"class":84},[59,1298,211],{"class":84},[59,1300,1301],{"class":91}," customers_truncate_audit\n",[59,1303,1304,1306,1309,1311,1314],{"class":61,"line":200},[59,1305,412],{"class":84},[59,1307,1308],{"class":84}," TRUNCATE",[59,1310,226],{"class":84},[59,1312,1313],{"class":95}," customers     ",[59,1315,1316],{"class":65},"-- TRUNCATE triggers are ALWAYS AFTER\n",[59,1318,1319,1321,1323,1326],{"class":61,"line":206},[59,1320,238],{"class":84},[59,1322,241],{"class":95},[59,1324,1325],{"class":84},"STATEMENT",[59,1327,1328],{"class":65},"              -- ALWAYS STATEMENT (no row-level)\n",[59,1330,1331,1333,1335],{"class":61,"line":217},[59,1332,253],{"class":84},[59,1334,88],{"class":84},[59,1336,1337],{"class":95}," audit_truncate();\n",[59,1339,1340],{"class":61,"line":235},[59,1341,1342],{"class":65},"-- Without this, a TRUNCATE customers; wipes the table with ZERO audit\n",[59,1344,1345],{"class":61,"line":250},[59,1346,1347],{"class":65},"-- trail — DELETE triggers don't fire on TRUNCATE. This is a common gap.\n",[42,1349,1351],{"id":1350},"trigger-ordering-alphabetical-by-name-postgresql","Trigger Ordering — alphabetical by name (PostgreSQL)",[14,1353,1354,1355,1358],{},"Multiple triggers on the same table\u002Fevent fire in ",[18,1356,1357],{},"alphabetical order by name",", not definition order. This is fragile — renaming a trigger changes the order.",[47,1360,1361],{"language":49},[51,1362,1364],{"className":53,"code":1363,"language":49,"meta":55,"style":55},"-- ❌ Fragile: firing order depends on names that might get renamed\nCREATE TRIGGER validate_email BEFORE INSERT ON users ...;\nCREATE TRIGGER set_defaults BEFORE INSERT ON users ...;\nCREATE TRIGGER audit_insert BEFORE INSERT ON users ...;\n-- Fires as: audit_insert → set_defaults → validate_email (alphabetical)\n-- If audit_insert assumes defaults are set, it runs FIRST — wrong order.\n\n-- ✅ Production: numeric prefix makes order explicit and stable\nCREATE TRIGGER 01_set_defaults  BEFORE INSERT ON users ...;\nCREATE TRIGGER 02_validate_email BEFORE INSERT ON users ...;\nCREATE TRIGGER 03_audit_insert  BEFORE INSERT ON users ...;\n-- Fires as: 01_set_defaults → 02_validate_email → 03_audit_insert\n-- Rename a function, not the trigger — the prefix pins the order.\n",[27,1365,1366,1371,1390,1407,1424,1429,1434,1438,1443,1460,1477,1494,1499],{"__ignoreMap":55},[59,1367,1368],{"class":61,"line":62},[59,1369,1370],{"class":65},"-- ❌ Fragile: firing order depends on names that might get renamed\n",[59,1372,1373,1375,1377,1380,1383,1385,1387],{"class":61,"line":69},[59,1374,85],{"class":84},[59,1376,211],{"class":84},[59,1378,1379],{"class":91}," validate_email",[59,1381,1382],{"class":84}," BEFORE",[59,1384,1013],{"class":84},[59,1386,226],{"class":84},[59,1388,1389],{"class":95}," users ...;\n",[59,1391,1392,1394,1396,1399,1401,1403,1405],{"class":61,"line":75},[59,1393,85],{"class":84},[59,1395,211],{"class":84},[59,1397,1398],{"class":91}," set_defaults",[59,1400,1382],{"class":84},[59,1402,1013],{"class":84},[59,1404,226],{"class":84},[59,1406,1389],{"class":95},[59,1408,1409,1411,1413,1416,1418,1420,1422],{"class":61,"line":81},[59,1410,85],{"class":84},[59,1412,211],{"class":84},[59,1414,1415],{"class":91}," audit_insert",[59,1417,1382],{"class":84},[59,1419,1013],{"class":84},[59,1421,226],{"class":84},[59,1423,1389],{"class":95},[59,1425,1426],{"class":61,"line":111},[59,1427,1428],{"class":65},"-- Fires as: audit_insert → set_defaults → validate_email (alphabetical)\n",[59,1430,1431],{"class":61,"line":117},[59,1432,1433],{"class":65},"-- If audit_insert assumes defaults are set, it runs FIRST — wrong order.\n",[59,1435,1436],{"class":61,"line":123},[59,1437,191],{"emptyLinePlaceholder":190},[59,1439,1440],{"class":61,"line":129},[59,1441,1442],{"class":65},"-- ✅ Production: numeric prefix makes order explicit and stable\n",[59,1444,1445,1447,1449,1452,1454,1456,1458],{"class":61,"line":154},[59,1446,85],{"class":84},[59,1448,211],{"class":84},[59,1450,1451],{"class":91}," 01_set_defaults",[59,1453,220],{"class":84},[59,1455,1013],{"class":84},[59,1457,226],{"class":84},[59,1459,1389],{"class":95},[59,1461,1462,1464,1466,1469,1471,1473,1475],{"class":61,"line":166},[59,1463,85],{"class":84},[59,1465,211],{"class":84},[59,1467,1468],{"class":91}," 02_validate_email",[59,1470,1382],{"class":84},[59,1472,1013],{"class":84},[59,1474,226],{"class":84},[59,1476,1389],{"class":95},[59,1478,1479,1481,1483,1486,1488,1490,1492],{"class":61,"line":175},[59,1480,85],{"class":84},[59,1482,211],{"class":84},[59,1484,1485],{"class":91}," 03_audit_insert",[59,1487,220],{"class":84},[59,1489,1013],{"class":84},[59,1491,226],{"class":84},[59,1493,1389],{"class":95},[59,1495,1496],{"class":61,"line":187},[59,1497,1498],{"class":65},"-- Fires as: 01_set_defaults → 02_validate_email → 03_audit_insert\n",[59,1500,1501],{"class":61,"line":194},[59,1502,1503],{"class":65},"-- Rename a function, not the trigger — the prefix pins the order.\n",[42,1505,1507],{"id":1506},"complex-implementation-production-audit-log-with-transition-tables","Complex Implementation — Production Audit Log with Transition Tables",[14,1509,1510,1511,1514,1515,1517],{},"A real audit system: ",[27,1512,1513],{},"AFTER STATEMENT"," trigger with transition tables, recording all changes in a single batch ",[27,1516,32],{},", with old\u002Fnew values as JSONB, operation type, timestamp, session user, and transaction ID.",[47,1519,1520],{"language":49},[51,1521,1523],{"className":53,"code":1522,"language":49,"meta":55,"style":55},"-- ── Audit log table ─────────────────────────────────────────────\nCREATE TABLE audit_log (\n  id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  table_name  TEXT NOT NULL,\n  operation   TEXT NOT NULL CHECK (operation IN ('INSERT','UPDATE','DELETE','TRUNCATE')),\n  row_id      BIGINT,                    -- nullable for TRUNCATE (no row)\n  old_data    JSONB,                     -- full row snapshot before change\n  new_data    JSONB,                     -- full row snapshot after change\n  changed_at  TIMESTAMPTZ NOT NULL DEFAULT now(),\n  changed_by  NAME NOT NULL DEFAULT current_user,   -- DB role\n  txid        BIGINT NOT NULL DEFAULT txid_current() -- transaction ID for grouping\n);\n\n-- Index for \"what changed in this transaction\" and \"who changed this row\"\nCREATE INDEX audit_log_txid_idx ON audit_log(txid);\nCREATE INDEX audit_log_table_row_idx ON audit_log(table_name, row_id);\nCREATE INDEX audit_log_changed_at_idx ON audit_log(changed_at DESC);\n\n-- ── The trigger function (statement-level, transition tables) ───\n-- Uses REFERENCING to get the full set of changed rows as pseudo-tables.\n-- One function call per statement, regardless of row count → bulk INSERT.\nCREATE FUNCTION audit_batch() RETURNS trigger AS $$\nBEGIN\n  -- INSERT: only new_table has rows\n  IF TG_OP = 'INSERT' THEN\n    INSERT INTO audit_log (table_name, operation, row_id, new_data, txid)\n    SELECT TG_TABLE_NAME, 'INSERT', id, to_jsonb(t), txid_current()\n    FROM new_table;                          -- new_table = all inserted rows\n\n  -- DELETE: only old_table has rows\n  ELSIF TG_OP = 'DELETE' THEN\n    INSERT INTO audit_log (table_name, operation, row_id, old_data, txid)\n    SELECT TG_TABLE_NAME, 'DELETE', id, to_jsonb(t), txid_current()\n    FROM old_table;                          -- old_table = all deleted rows\n\n  -- UPDATE: both old_table and new_table have rows, joined by PK\n  ELSIF TG_OP = 'UPDATE' THEN\n    INSERT INTO audit_log (table_name, operation, row_id, old_data, new_data, txid)\n    SELECT TG_TABLE_NAME, 'UPDATE',\n           n.id,                              -- new row's PK\n           to_jsonb(o),                       -- old snapshot\n           to_jsonb(n),                       -- new snapshot\n           txid_current()\n    FROM old_table o                          -- old_table: pre-update rows\n    JOIN new_table n ON o.id = n.id;          -- new_table: post-update rows\n    -- JOIN on PK (id) aligns old and new versions of the same row.\n    -- If your PK isn't `id`, adjust the join column.\n  END IF;\n\n  RETURN NULL;   -- statement-level: return value ignored\nEND;\n$$ LANGUAGE plpgsql;\n\n-- ── Bind it: one trigger, all three DML events, statement-level ─\nCREATE TRIGGER orders_audit_batch\n  AFTER INSERT OR UPDATE OR DELETE ON orders\n  REFERENCING NEW TABLE AS new_table OLD TABLE AS old_table\n  FOR EACH STATEMENT\n  EXECUTE FUNCTION audit_batch();\n\n-- ── Test: a bulk UPDATE fires the trigger ONCE, audits all rows ─\nUPDATE orders SET status = 'shipped' WHERE status = 'pending';\n-- → audit_log gets N rows (one per changed order) in a single INSERT...SELECT\n-- → all share the same txid → grouped as one transaction's changes\n-- → a row-level trigger would have fired the function N times (N function calls)\n",[27,1524,1525,1530,1542,1567,1580,1616,1629,1637,1645,1663,1681,1698,1703,1707,1712,1727,1741,1760,1764,1769,1774,1779,1798,1803,1809,1826,1835,1849,1861,1866,1872,1885,1893,1904,1915,1920,1926,1938,1946,1957,1973,1982,1991,1997,2008,2042,2048,2054,2065,2070,2082,2089,2098,2103,2109,2119,2138,2157,2166,2176,2181,2187,2218,2224,2230],{"__ignoreMap":55},[59,1526,1527],{"class":61,"line":62},[59,1528,1529],{"class":65},"-- ── Audit log table ─────────────────────────────────────────────\n",[59,1531,1532,1534,1537,1540],{"class":61,"line":69},[59,1533,85],{"class":84},[59,1535,1536],{"class":84}," TABLE",[59,1538,1539],{"class":91}," audit_log",[59,1541,822],{"class":95},[59,1543,1544,1547,1550,1553,1556,1558,1561,1564],{"class":61,"line":75},[59,1545,1546],{"class":95},"  id          ",[59,1548,1549],{"class":84},"BIGINT",[59,1551,1552],{"class":84}," GENERATED",[59,1554,1555],{"class":84}," ALWAYS",[59,1557,486],{"class":84},[59,1559,1560],{"class":84}," IDENTITY",[59,1562,1563],{"class":84}," PRIMARY KEY",[59,1565,1566],{"class":95},",\n",[59,1568,1569,1572,1575,1578],{"class":61,"line":81},[59,1570,1571],{"class":95},"  table_name  ",[59,1573,1574],{"class":84},"TEXT",[59,1576,1577],{"class":84}," NOT NULL",[59,1579,1566],{"class":95},[59,1581,1582,1585,1587,1589,1592,1595,1597,1599,1601,1603,1605,1607,1609,1611,1613],{"class":61,"line":111},[59,1583,1584],{"class":95},"  operation   ",[59,1586,1574],{"class":84},[59,1588,1577],{"class":84},[59,1590,1591],{"class":84}," CHECK",[59,1593,1594],{"class":95}," (operation ",[59,1596,882],{"class":84},[59,1598,885],{"class":95},[59,1600,658],{"class":888},[59,1602,891],{"class":95},[59,1604,661],{"class":888},[59,1606,891],{"class":95},[59,1608,664],{"class":888},[59,1610,891],{"class":95},[59,1612,667],{"class":888},[59,1614,1615],{"class":95},")),\n",[59,1617,1618,1621,1623,1626],{"class":61,"line":117},[59,1619,1620],{"class":95},"  row_id      ",[59,1622,1549],{"class":84},[59,1624,1625],{"class":95},",                    ",[59,1627,1628],{"class":65},"-- nullable for TRUNCATE (no row)\n",[59,1630,1631,1634],{"class":61,"line":123},[59,1632,1633],{"class":95},"  old_data    JSONB,                     ",[59,1635,1636],{"class":65},"-- full row snapshot before change\n",[59,1638,1639,1642],{"class":61,"line":129},[59,1640,1641],{"class":95},"  new_data    JSONB,                     ",[59,1643,1644],{"class":65},"-- full row snapshot after change\n",[59,1646,1647,1650,1653,1655,1658,1660],{"class":61,"line":154},[59,1648,1649],{"class":95},"  changed_at  ",[59,1651,1652],{"class":84},"TIMESTAMPTZ",[59,1654,1577],{"class":84},[59,1656,1657],{"class":84}," DEFAULT",[59,1659,145],{"class":84},[59,1661,1662],{"class":95},"(),\n",[59,1664,1665,1668,1671,1673,1675,1678],{"class":61,"line":166},[59,1666,1667],{"class":95},"  changed_by  ",[59,1669,1670],{"class":84},"NAME",[59,1672,1577],{"class":84},[59,1674,1657],{"class":84},[59,1676,1677],{"class":95}," current_user,   ",[59,1679,1680],{"class":65},"-- DB role\n",[59,1682,1683,1686,1688,1690,1692,1695],{"class":61,"line":175},[59,1684,1685],{"class":95},"  txid        ",[59,1687,1549],{"class":84},[59,1689,1577],{"class":84},[59,1691,1657],{"class":84},[59,1693,1694],{"class":95}," txid_current() ",[59,1696,1697],{"class":65},"-- transaction ID for grouping\n",[59,1699,1700],{"class":61,"line":187},[59,1701,1702],{"class":95},");\n",[59,1704,1705],{"class":61,"line":194},[59,1706,191],{"emptyLinePlaceholder":190},[59,1708,1709],{"class":61,"line":200},[59,1710,1711],{"class":65},"-- Index for \"what changed in this transaction\" and \"who changed this row\"\n",[59,1713,1714,1716,1719,1722,1724],{"class":61,"line":206},[59,1715,85],{"class":84},[59,1717,1718],{"class":84}," INDEX",[59,1720,1721],{"class":91}," audit_log_txid_idx",[59,1723,226],{"class":84},[59,1725,1726],{"class":95}," audit_log(txid);\n",[59,1728,1729,1731,1733,1736,1738],{"class":61,"line":217},[59,1730,85],{"class":84},[59,1732,1718],{"class":84},[59,1734,1735],{"class":91}," audit_log_table_row_idx",[59,1737,226],{"class":84},[59,1739,1740],{"class":95}," audit_log(table_name, row_id);\n",[59,1742,1743,1745,1747,1750,1752,1755,1758],{"class":61,"line":235},[59,1744,85],{"class":84},[59,1746,1718],{"class":84},[59,1748,1749],{"class":91}," audit_log_changed_at_idx",[59,1751,226],{"class":84},[59,1753,1754],{"class":95}," audit_log(changed_at ",[59,1756,1757],{"class":84},"DESC",[59,1759,1702],{"class":95},[59,1761,1762],{"class":61,"line":250},[59,1763,191],{"emptyLinePlaceholder":190},[59,1765,1766],{"class":61,"line":261},[59,1767,1768],{"class":65},"-- ── The trigger function (statement-level, transition tables) ───\n",[59,1770,1771],{"class":61,"line":267},[59,1772,1773],{"class":65},"-- Uses REFERENCING to get the full set of changed rows as pseudo-tables.\n",[59,1775,1776],{"class":61,"line":273},[59,1777,1778],{"class":65},"-- One function call per statement, regardless of row count → bulk INSERT.\n",[59,1780,1781,1783,1785,1788,1790,1792,1794,1796],{"class":61,"line":1034},[59,1782,85],{"class":84},[59,1784,88],{"class":84},[59,1786,1787],{"class":91}," audit_batch",[59,1789,96],{"class":95},[59,1791,99],{"class":84},[59,1793,102],{"class":95},[59,1795,105],{"class":84},[59,1797,108],{"class":95},[59,1799,1801],{"class":61,"line":1800},23,[59,1802,114],{"class":84},[59,1804,1806],{"class":61,"line":1805},24,[59,1807,1808],{"class":65},"  -- INSERT: only new_table has rows\n",[59,1810,1812,1815,1817,1820,1823],{"class":61,"line":1811},25,[59,1813,1814],{"class":84},"  IF",[59,1816,879],{"class":95},[59,1818,1819],{"class":84},"=",[59,1821,1822],{"class":888}," 'INSERT'",[59,1824,1825],{"class":84}," THEN\n",[59,1827,1829,1832],{"class":61,"line":1828},26,[59,1830,1831],{"class":84},"    INSERT INTO",[59,1833,1834],{"class":95}," audit_log (table_name, operation, row_id, new_data, txid)\n",[59,1836,1838,1841,1844,1846],{"class":61,"line":1837},27,[59,1839,1840],{"class":84},"    SELECT",[59,1842,1843],{"class":95}," TG_TABLE_NAME, ",[59,1845,658],{"class":888},[59,1847,1848],{"class":95},", id, to_jsonb(t), txid_current()\n",[59,1850,1852,1855,1858],{"class":61,"line":1851},28,[59,1853,1854],{"class":84},"    FROM",[59,1856,1857],{"class":95}," new_table;                          ",[59,1859,1860],{"class":65},"-- new_table = all inserted rows\n",[59,1862,1864],{"class":61,"line":1863},29,[59,1865,191],{"emptyLinePlaceholder":190},[59,1867,1869],{"class":61,"line":1868},30,[59,1870,1871],{"class":65},"  -- DELETE: only old_table has rows\n",[59,1873,1875,1878,1880,1883],{"class":61,"line":1874},31,[59,1876,1877],{"class":95},"  ELSIF TG_OP ",[59,1879,1819],{"class":84},[59,1881,1882],{"class":888}," 'DELETE'",[59,1884,1825],{"class":84},[59,1886,1888,1890],{"class":61,"line":1887},32,[59,1889,1831],{"class":84},[59,1891,1892],{"class":95}," audit_log (table_name, operation, row_id, old_data, txid)\n",[59,1894,1896,1898,1900,1902],{"class":61,"line":1895},33,[59,1897,1840],{"class":84},[59,1899,1843],{"class":95},[59,1901,664],{"class":888},[59,1903,1848],{"class":95},[59,1905,1907,1909,1912],{"class":61,"line":1906},34,[59,1908,1854],{"class":84},[59,1910,1911],{"class":95}," old_table;                          ",[59,1913,1914],{"class":65},"-- old_table = all deleted rows\n",[59,1916,1918],{"class":61,"line":1917},35,[59,1919,191],{"emptyLinePlaceholder":190},[59,1921,1923],{"class":61,"line":1922},36,[59,1924,1925],{"class":65},"  -- UPDATE: both old_table and new_table have rows, joined by PK\n",[59,1927,1929,1931,1933,1936],{"class":61,"line":1928},37,[59,1930,1877],{"class":95},[59,1932,1819],{"class":84},[59,1934,1935],{"class":888}," 'UPDATE'",[59,1937,1825],{"class":84},[59,1939,1941,1943],{"class":61,"line":1940},38,[59,1942,1831],{"class":84},[59,1944,1945],{"class":95}," audit_log (table_name, operation, row_id, old_data, new_data, txid)\n",[59,1947,1949,1951,1953,1955],{"class":61,"line":1948},39,[59,1950,1840],{"class":84},[59,1952,1843],{"class":95},[59,1954,661],{"class":888},[59,1956,1566],{"class":95},[59,1958,1960,1963,1965,1967,1970],{"class":61,"line":1959},40,[59,1961,1962],{"class":132},"           n",[59,1964,136],{"class":95},[59,1966,853],{"class":132},[59,1968,1969],{"class":95},",                              ",[59,1971,1972],{"class":65},"-- new row's PK\n",[59,1974,1976,1979],{"class":61,"line":1975},41,[59,1977,1978],{"class":95},"           to_jsonb(o),                       ",[59,1980,1981],{"class":65},"-- old snapshot\n",[59,1983,1985,1988],{"class":61,"line":1984},42,[59,1986,1987],{"class":95},"           to_jsonb(n),                       ",[59,1989,1990],{"class":65},"-- new snapshot\n",[59,1992,1994],{"class":61,"line":1993},43,[59,1995,1996],{"class":95},"           txid_current()\n",[59,1998,2000,2002,2005],{"class":61,"line":1999},44,[59,2001,1854],{"class":84},[59,2003,2004],{"class":95}," old_table o                          ",[59,2006,2007],{"class":65},"-- old_table: pre-update rows\n",[59,2009,2011,2014,2017,2020,2023,2025,2027,2029,2032,2034,2036,2039],{"class":61,"line":2010},45,[59,2012,2013],{"class":84},"    JOIN",[59,2015,2016],{"class":95}," new_table n ",[59,2018,2019],{"class":84},"ON",[59,2021,2022],{"class":132}," o",[59,2024,136],{"class":95},[59,2026,853],{"class":132},[59,2028,142],{"class":84},[59,2030,2031],{"class":132}," n",[59,2033,136],{"class":95},[59,2035,853],{"class":132},[59,2037,2038],{"class":95},";          ",[59,2040,2041],{"class":65},"-- new_table: post-update rows\n",[59,2043,2045],{"class":61,"line":2044},46,[59,2046,2047],{"class":65},"    -- JOIN on PK (id) aligns old and new versions of the same row.\n",[59,2049,2051],{"class":61,"line":2050},47,[59,2052,2053],{"class":65},"    -- If your PK isn't `id`, adjust the join column.\n",[59,2055,2057,2060,2063],{"class":61,"line":2056},48,[59,2058,2059],{"class":84},"  END",[59,2061,2062],{"class":84}," IF",[59,2064,172],{"class":95},[59,2066,2068],{"class":61,"line":2067},49,[59,2069,191],{"emptyLinePlaceholder":190},[59,2071,2073,2075,2077,2079],{"class":61,"line":2072},50,[59,2074,157],{"class":84},[59,2076,1268],{"class":84},[59,2078,1271],{"class":95},[59,2080,2081],{"class":65},"-- statement-level: return value ignored\n",[59,2083,2085,2087],{"class":61,"line":2084},51,[59,2086,169],{"class":84},[59,2088,172],{"class":95},[59,2090,2092,2094,2096],{"class":61,"line":2091},52,[59,2093,178],{"class":95},[59,2095,181],{"class":84},[59,2097,184],{"class":95},[59,2099,2101],{"class":61,"line":2100},53,[59,2102,191],{"emptyLinePlaceholder":190},[59,2104,2106],{"class":61,"line":2105},54,[59,2107,2108],{"class":65},"-- ── Bind it: one trigger, all three DML events, statement-level ─\n",[59,2110,2112,2114,2116],{"class":61,"line":2111},55,[59,2113,85],{"class":84},[59,2115,211],{"class":84},[59,2117,2118],{"class":91}," orders_audit_batch\n",[59,2120,2122,2124,2126,2128,2130,2132,2134,2136],{"class":61,"line":2121},56,[59,2123,412],{"class":84},[59,2125,1013],{"class":84},[59,2127,1016],{"class":84},[59,2129,223],{"class":84},[59,2131,1016],{"class":84},[59,2133,1023],{"class":84},[59,2135,226],{"class":84},[59,2137,419],{"class":95},[59,2139,2141,2143,2145,2147,2150,2152,2154],{"class":61,"line":2140},57,[59,2142,480],{"class":95},[59,2144,483],{"class":84},[59,2146,486],{"class":84},[59,2148,2149],{"class":95}," new_table OLD ",[59,2151,483],{"class":84},[59,2153,486],{"class":84},[59,2155,2156],{"class":95}," old_table\n",[59,2158,2160,2162,2164],{"class":61,"line":2159},58,[59,2161,238],{"class":84},[59,2163,241],{"class":95},[59,2165,505],{"class":84},[59,2167,2169,2171,2173],{"class":61,"line":2168},59,[59,2170,253],{"class":84},[59,2172,88],{"class":84},[59,2174,2175],{"class":95}," audit_batch();\n",[59,2177,2179],{"class":61,"line":2178},60,[59,2180,191],{"emptyLinePlaceholder":190},[59,2182,2184],{"class":61,"line":2183},61,[59,2185,2186],{"class":65},"-- ── Test: a bulk UPDATE fires the trigger ONCE, audits all rows ─\n",[59,2188,2190,2192,2195,2198,2201,2203,2206,2209,2211,2213,2216],{"class":61,"line":2189},62,[59,2191,36],{"class":84},[59,2193,2194],{"class":95}," orders ",[59,2196,2197],{"class":84},"SET",[59,2199,2200],{"class":84}," status",[59,2202,142],{"class":84},[59,2204,2205],{"class":888}," 'shipped'",[59,2207,2208],{"class":84}," WHERE",[59,2210,2200],{"class":84},[59,2212,142],{"class":84},[59,2214,2215],{"class":888}," 'pending'",[59,2217,172],{"class":95},[59,2219,2221],{"class":61,"line":2220},63,[59,2222,2223],{"class":65},"-- → audit_log gets N rows (one per changed order) in a single INSERT...SELECT\n",[59,2225,2227],{"class":61,"line":2226},64,[59,2228,2229],{"class":65},"-- → all share the same txid → grouped as one transaction's changes\n",[59,2231,2233],{"class":61,"line":2232},65,[59,2234,2235],{"class":65},"-- → a row-level trigger would have fired the function N times (N function calls)\n",[42,2237,2239],{"id":2238},"anti-pattern-triggers-for-business-logic","Anti-Pattern: Triggers for Business Logic",[47,2241,2242],{"language":49},[51,2243,2245],{"className":53,"code":2244,"language":49,"meta":55,"style":55},"-- ❌ WRONG: business rule hidden in a trigger. The application's\n-- `UPDATE orders SET status = 'shipped'` silently enforces a rule\n-- the app code doesn't know about. Debugging: \"why did my UPDATE\n-- fail?\" → hunt through pg_trigger, not the app stack trace.\nCREATE FUNCTION enforce_shipping_rule() RETURNS trigger AS $$\nBEGIN\n  -- Business rule: can't ship if payment isn't settled.\n  -- This belongs in the application (visible, testable, in git)\n  -- or in a CHECK constraint (if expressible), not a trigger.\n  IF NEW.status = 'shipped' AND NOT EXISTS (\n    SELECT 1 FROM payments p\n    WHERE p.order_id = NEW.id AND p.status = 'settled'\n  ) THEN\n    RAISE EXCEPTION 'Cannot ship order %: payment not settled', NEW.id;\n  END IF;\n  RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER check_shipping_rule\n  BEFORE UPDATE OF status ON orders\n  FOR EACH ROW EXECUTE FUNCTION enforce_shipping_rule();\n\n-- ✅ RIGHT: put it in the application where it's visible and testable.\n-- The app checks payment status before issuing the UPDATE. If a constraint\n-- is needed as a backstop, use a CHECK (if self-contained) or a deferred\n-- FK, not a trigger with hidden cross-table logic.\n",[27,2246,2247,2252,2257,2262,2267,2286,2290,2295,2300,2305,2332,2344,2378,2386,2404,2412,2419,2425,2433,2437,2446,2461,2476,2480,2485,2490,2495],{"__ignoreMap":55},[59,2248,2249],{"class":61,"line":62},[59,2250,2251],{"class":65},"-- ❌ WRONG: business rule hidden in a trigger. The application's\n",[59,2253,2254],{"class":61,"line":69},[59,2255,2256],{"class":65},"-- `UPDATE orders SET status = 'shipped'` silently enforces a rule\n",[59,2258,2259],{"class":61,"line":75},[59,2260,2261],{"class":65},"-- the app code doesn't know about. Debugging: \"why did my UPDATE\n",[59,2263,2264],{"class":61,"line":81},[59,2265,2266],{"class":65},"-- fail?\" → hunt through pg_trigger, not the app stack trace.\n",[59,2268,2269,2271,2273,2276,2278,2280,2282,2284],{"class":61,"line":111},[59,2270,85],{"class":84},[59,2272,88],{"class":84},[59,2274,2275],{"class":91}," enforce_shipping_rule",[59,2277,96],{"class":95},[59,2279,99],{"class":84},[59,2281,102],{"class":95},[59,2283,105],{"class":84},[59,2285,108],{"class":95},[59,2287,2288],{"class":61,"line":117},[59,2289,114],{"class":84},[59,2291,2292],{"class":61,"line":123},[59,2293,2294],{"class":65},"  -- Business rule: can't ship if payment isn't settled.\n",[59,2296,2297],{"class":61,"line":129},[59,2298,2299],{"class":65},"  -- This belongs in the application (visible, testable, in git)\n",[59,2301,2302],{"class":61,"line":154},[59,2303,2304],{"class":65},"  -- or in a CHECK constraint (if expressible), not a trigger.\n",[59,2306,2307,2309,2312,2314,2317,2319,2321,2324,2327,2330],{"class":61,"line":166},[59,2308,1814],{"class":84},[59,2310,2311],{"class":132}," NEW",[59,2313,136],{"class":95},[59,2315,2316],{"class":132},"status",[59,2318,142],{"class":84},[59,2320,2205],{"class":888},[59,2322,2323],{"class":84}," AND",[59,2325,2326],{"class":84}," NOT",[59,2328,2329],{"class":84}," EXISTS",[59,2331,822],{"class":95},[59,2333,2334,2336,2339,2341],{"class":61,"line":175},[59,2335,1840],{"class":84},[59,2337,2338],{"class":132}," 1",[59,2340,1134],{"class":84},[59,2342,2343],{"class":95}," payments p\n",[59,2345,2346,2349,2352,2354,2357,2359,2361,2363,2365,2367,2369,2371,2373,2375],{"class":61,"line":187},[59,2347,2348],{"class":84},"    WHERE",[59,2350,2351],{"class":132}," p",[59,2353,136],{"class":95},[59,2355,2356],{"class":132},"order_id",[59,2358,142],{"class":84},[59,2360,2311],{"class":132},[59,2362,136],{"class":95},[59,2364,853],{"class":132},[59,2366,2323],{"class":84},[59,2368,2351],{"class":132},[59,2370,136],{"class":95},[59,2372,2316],{"class":132},[59,2374,142],{"class":84},[59,2376,2377],{"class":888}," 'settled'\n",[59,2379,2380,2383],{"class":61,"line":194},[59,2381,2382],{"class":95},"  ) ",[59,2384,2385],{"class":84},"THEN\n",[59,2387,2388,2391,2394,2396,2398,2400,2402],{"class":61,"line":200},[59,2389,2390],{"class":95},"    RAISE EXCEPTION ",[59,2392,2393],{"class":888},"'Cannot ship order %: payment not settled'",[59,2395,856],{"class":95},[59,2397,322],{"class":132},[59,2399,136],{"class":95},[59,2401,853],{"class":132},[59,2403,172],{"class":95},[59,2405,2406,2408,2410],{"class":61,"line":206},[59,2407,2059],{"class":84},[59,2409,2062],{"class":84},[59,2411,172],{"class":95},[59,2413,2414,2416],{"class":61,"line":217},[59,2415,157],{"class":84},[59,2417,2418],{"class":95}," NEW;\n",[59,2420,2421,2423],{"class":61,"line":235},[59,2422,169],{"class":84},[59,2424,172],{"class":95},[59,2426,2427,2429,2431],{"class":61,"line":250},[59,2428,178],{"class":95},[59,2430,181],{"class":84},[59,2432,184],{"class":95},[59,2434,2435],{"class":61,"line":261},[59,2436,191],{"emptyLinePlaceholder":190},[59,2438,2439,2441,2443],{"class":61,"line":267},[59,2440,85],{"class":84},[59,2442,211],{"class":84},[59,2444,2445],{"class":91}," check_shipping_rule\n",[59,2447,2448,2450,2452,2455,2457,2459],{"class":61,"line":273},[59,2449,220],{"class":84},[59,2451,223],{"class":84},[59,2453,2454],{"class":95}," OF ",[59,2456,2316],{"class":84},[59,2458,226],{"class":84},[59,2460,419],{"class":95},[59,2462,2463,2465,2467,2469,2471,2473],{"class":61,"line":1034},[59,2464,238],{"class":84},[59,2466,241],{"class":95},[59,2468,244],{"class":84},[59,2470,1043],{"class":84},[59,2472,88],{"class":84},[59,2474,2475],{"class":95}," enforce_shipping_rule();\n",[59,2477,2478],{"class":61,"line":1800},[59,2479,191],{"emptyLinePlaceholder":190},[59,2481,2482],{"class":61,"line":1805},[59,2483,2484],{"class":65},"-- ✅ RIGHT: put it in the application where it's visible and testable.\n",[59,2486,2487],{"class":61,"line":1811},[59,2488,2489],{"class":65},"-- The app checks payment status before issuing the UPDATE. If a constraint\n",[59,2491,2492],{"class":61,"line":1828},[59,2493,2494],{"class":65},"-- is needed as a backstop, use a CHECK (if self-contained) or a deferred\n",[59,2496,2497],{"class":61,"line":1837},[59,2498,2499],{"class":65},"-- FK, not a trigger with hidden cross-table logic.\n",[14,2501,2502,2505,2506,2509],{},[18,2503,2504],{},"Why triggers are bad for business logic",": invisible to the application, invisible in code review, invisible in git history of the app, can't be unit-tested without a database, fire on ",[27,2507,2508],{},"COPY","\u002Fbulk loads (unexpected), and make \"given these SQL statements, what's the final state?\" impossible to reason about.",[42,2511,2513],{"id":2512},"anti-pattern-before-trigger-for-audit-cant-see-final-state","Anti-Pattern: BEFORE Trigger for Audit (Can't See Final State)",[47,2515,2516],{"language":49},[51,2517,2519],{"className":53,"code":2518,"language":49,"meta":55,"style":55},"-- ❌ WRONG: BEFORE trigger for audit. It fires BEFORE the row is\n-- written, so it logs the INTENDED state, not the ACTUAL state.\n-- If a constraint fails after the BEFORE trigger runs (e.g., a\n-- deferred FK, or another BEFORE trigger rejects the row), the\n-- audit log records a change that never happened.\nCREATE FUNCTION audit_before() RETURNS trigger AS $$\nBEGIN\n  INSERT INTO audit_log (operation, new_data) VALUES ('UPDATE', to_jsonb(NEW));\n  RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n-- If the UPDATE is later rolled back, the audit entry is WRONG\n-- (logs a change that was reverted). Also: the audit INSERT is in\n-- the same transaction — if the txn rolls back, the audit row is\n-- rolled back too (no record of the attempted change).\n\n-- ✅ RIGHT: AFTER trigger fires after the write succeeds. The row\n-- is in the table. But it's still in the same transaction — if the\n-- whole txn rolls back, the audit row rolls back too. For a truly\n-- durable audit trail (survives rollback), use a separate transaction\n-- via dblink or pg_notify + an async consumer — advanced, rare.\nCREATE FUNCTION audit_after() RETURNS trigger AS $$\nBEGIN\n  INSERT INTO audit_log (operation, new_data) VALUES ('UPDATE', to_jsonb(NEW));\n  RETURN NULL;   -- AFTER: return value ignored\nEND;\n$$ LANGUAGE plpgsql;\nCREATE TRIGGER orders_audit\n  AFTER UPDATE ON orders\n  FOR EACH ROW EXECUTE FUNCTION audit_after();\n",[27,2520,2521,2526,2531,2536,2541,2546,2565,2569,2586,2592,2598,2606,2611,2616,2621,2626,2630,2635,2640,2645,2650,2655,2674,2678,2692,2703,2709,2717,2726,2736],{"__ignoreMap":55},[59,2522,2523],{"class":61,"line":62},[59,2524,2525],{"class":65},"-- ❌ WRONG: BEFORE trigger for audit. It fires BEFORE the row is\n",[59,2527,2528],{"class":61,"line":69},[59,2529,2530],{"class":65},"-- written, so it logs the INTENDED state, not the ACTUAL state.\n",[59,2532,2533],{"class":61,"line":75},[59,2534,2535],{"class":65},"-- If a constraint fails after the BEFORE trigger runs (e.g., a\n",[59,2537,2538],{"class":61,"line":81},[59,2539,2540],{"class":65},"-- deferred FK, or another BEFORE trigger rejects the row), the\n",[59,2542,2543],{"class":61,"line":111},[59,2544,2545],{"class":65},"-- audit log records a change that never happened.\n",[59,2547,2548,2550,2552,2555,2557,2559,2561,2563],{"class":61,"line":117},[59,2549,85],{"class":84},[59,2551,88],{"class":84},[59,2553,2554],{"class":91}," audit_before",[59,2556,96],{"class":95},[59,2558,99],{"class":84},[59,2560,102],{"class":95},[59,2562,105],{"class":84},[59,2564,108],{"class":95},[59,2566,2567],{"class":61,"line":123},[59,2568,114],{"class":84},[59,2570,2571,2573,2576,2579,2581,2583],{"class":61,"line":129},[59,2572,811],{"class":84},[59,2574,2575],{"class":95}," audit_log (operation, new_data) ",[59,2577,2578],{"class":84},"VALUES",[59,2580,885],{"class":95},[59,2582,661],{"class":888},[59,2584,2585],{"class":95},", to_jsonb(NEW));\n",[59,2587,2588,2590],{"class":61,"line":154},[59,2589,157],{"class":84},[59,2591,2418],{"class":95},[59,2593,2594,2596],{"class":61,"line":166},[59,2595,169],{"class":84},[59,2597,172],{"class":95},[59,2599,2600,2602,2604],{"class":61,"line":175},[59,2601,178],{"class":95},[59,2603,181],{"class":84},[59,2605,184],{"class":95},[59,2607,2608],{"class":61,"line":187},[59,2609,2610],{"class":65},"-- If the UPDATE is later rolled back, the audit entry is WRONG\n",[59,2612,2613],{"class":61,"line":194},[59,2614,2615],{"class":65},"-- (logs a change that was reverted). Also: the audit INSERT is in\n",[59,2617,2618],{"class":61,"line":200},[59,2619,2620],{"class":65},"-- the same transaction — if the txn rolls back, the audit row is\n",[59,2622,2623],{"class":61,"line":206},[59,2624,2625],{"class":65},"-- rolled back too (no record of the attempted change).\n",[59,2627,2628],{"class":61,"line":217},[59,2629,191],{"emptyLinePlaceholder":190},[59,2631,2632],{"class":61,"line":235},[59,2633,2634],{"class":65},"-- ✅ RIGHT: AFTER trigger fires after the write succeeds. The row\n",[59,2636,2637],{"class":61,"line":250},[59,2638,2639],{"class":65},"-- is in the table. But it's still in the same transaction — if the\n",[59,2641,2642],{"class":61,"line":261},[59,2643,2644],{"class":65},"-- whole txn rolls back, the audit row rolls back too. For a truly\n",[59,2646,2647],{"class":61,"line":267},[59,2648,2649],{"class":65},"-- durable audit trail (survives rollback), use a separate transaction\n",[59,2651,2652],{"class":61,"line":273},[59,2653,2654],{"class":65},"-- via dblink or pg_notify + an async consumer — advanced, rare.\n",[59,2656,2657,2659,2661,2664,2666,2668,2670,2672],{"class":61,"line":1034},[59,2658,85],{"class":84},[59,2660,88],{"class":84},[59,2662,2663],{"class":91}," audit_after",[59,2665,96],{"class":95},[59,2667,99],{"class":84},[59,2669,102],{"class":95},[59,2671,105],{"class":84},[59,2673,108],{"class":95},[59,2675,2676],{"class":61,"line":1800},[59,2677,114],{"class":84},[59,2679,2680,2682,2684,2686,2688,2690],{"class":61,"line":1805},[59,2681,811],{"class":84},[59,2683,2575],{"class":95},[59,2685,2578],{"class":84},[59,2687,885],{"class":95},[59,2689,661],{"class":888},[59,2691,2585],{"class":95},[59,2693,2694,2696,2698,2700],{"class":61,"line":1811},[59,2695,157],{"class":84},[59,2697,1268],{"class":84},[59,2699,1271],{"class":95},[59,2701,2702],{"class":65},"-- AFTER: return value ignored\n",[59,2704,2705,2707],{"class":61,"line":1828},[59,2706,169],{"class":84},[59,2708,172],{"class":95},[59,2710,2711,2713,2715],{"class":61,"line":1837},[59,2712,178],{"class":95},[59,2714,181],{"class":84},[59,2716,184],{"class":95},[59,2718,2719,2721,2723],{"class":61,"line":1851},[59,2720,85],{"class":84},[59,2722,211],{"class":84},[59,2724,2725],{"class":91}," orders_audit\n",[59,2727,2728,2730,2732,2734],{"class":61,"line":1863},[59,2729,412],{"class":84},[59,2731,223],{"class":84},[59,2733,226],{"class":84},[59,2735,419],{"class":95},[59,2737,2738,2740,2742,2744,2746,2748],{"class":61,"line":1868},[59,2739,238],{"class":84},[59,2741,241],{"class":95},[59,2743,244],{"class":84},[59,2745,1043],{"class":84},[59,2747,88],{"class":84},[59,2749,2750],{"class":95}," audit_after();\n",[42,2752,2754],{"id":2753},"trigger-recursion-the-infinite-loop","Trigger Recursion — the infinite loop",[47,2756,2757],{"language":49},[51,2758,2760],{"className":53,"code":2759,"language":49,"meta":55,"style":55},"-- ❌ DANGER: trigger on A updates B, trigger on B updates A → recursion\nCREATE FUNCTION a_touches_b() RETURNS trigger AS $$\nBEGIN\n  UPDATE b SET last_sync = now() WHERE a_id = NEW.id;  -- triggers b_touches_a\n  RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE FUNCTION b_touches_a() RETURNS trigger AS $$\nBEGIN\n  UPDATE a SET last_sync = now() WHERE id = NEW.a_id;  -- triggers a_touches_b\n  RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER a_sync AFTER UPDATE ON a FOR EACH ROW EXECUTE FUNCTION a_touches_b();\nCREATE TRIGGER b_sync AFTER UPDATE ON b FOR EACH ROW EXECUTE FUNCTION b_touches_a();\n\n-- UPDATE a SET x = 1 WHERE id = 1;\n-- → a_sync fires → UPDATE b → b_sync fires → UPDATE a → a_sync fires → ...\n-- PostgreSQL has a stack depth limit → ERROR: stack depth limit exceeded.\n-- Even without the stack limit, this is an infinite loop.\n\n-- ✅ Fix: use a session variable to break the cycle, or restructure so\n-- only one direction has a trigger, or use a flag column to avoid\n-- re-triggering.\nCREATE FUNCTION a_touches_b() RETURNS trigger AS $$\nBEGIN\n  IF current_setting('app.suppress_b_sync', true) IS NULL THEN\n    PERFORM set_config('app.suppress_b_sync', '1', true);  -- local to txn\n    UPDATE b SET last_sync = now() WHERE a_id = NEW.id;\n    PERFORM set_config('app.suppress_b_sync', NULL, true);\n  END IF;\n  RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n",[27,2761,2762,2767,2786,2790,2829,2835,2841,2849,2853,2872,2876,2912,2918,2924,2932,2936,2968,2998,3002,3007,3012,3017,3022,3026,3031,3036,3041,3059,3063,3083,3101,3132,3145,3153,3159,3165],{"__ignoreMap":55},[59,2763,2764],{"class":61,"line":62},[59,2765,2766],{"class":65},"-- ❌ DANGER: trigger on A updates B, trigger on B updates A → recursion\n",[59,2768,2769,2771,2773,2776,2778,2780,2782,2784],{"class":61,"line":69},[59,2770,85],{"class":84},[59,2772,88],{"class":84},[59,2774,2775],{"class":91}," a_touches_b",[59,2777,96],{"class":95},[59,2779,99],{"class":84},[59,2781,102],{"class":95},[59,2783,105],{"class":84},[59,2785,108],{"class":95},[59,2787,2788],{"class":61,"line":75},[59,2789,114],{"class":84},[59,2791,2792,2795,2798,2800,2803,2805,2807,2809,2812,2815,2817,2819,2821,2823,2826],{"class":61,"line":81},[59,2793,2794],{"class":84},"  UPDATE",[59,2796,2797],{"class":95}," b ",[59,2799,2197],{"class":84},[59,2801,2802],{"class":95}," last_sync ",[59,2804,1819],{"class":84},[59,2806,145],{"class":84},[59,2808,96],{"class":95},[59,2810,2811],{"class":84},"WHERE",[59,2813,2814],{"class":95}," a_id ",[59,2816,1819],{"class":84},[59,2818,2311],{"class":132},[59,2820,136],{"class":95},[59,2822,853],{"class":132},[59,2824,2825],{"class":95},";  ",[59,2827,2828],{"class":65},"-- triggers b_touches_a\n",[59,2830,2831,2833],{"class":61,"line":111},[59,2832,157],{"class":84},[59,2834,2418],{"class":95},[59,2836,2837,2839],{"class":61,"line":117},[59,2838,169],{"class":84},[59,2840,172],{"class":95},[59,2842,2843,2845,2847],{"class":61,"line":123},[59,2844,178],{"class":95},[59,2846,181],{"class":84},[59,2848,184],{"class":95},[59,2850,2851],{"class":61,"line":129},[59,2852,191],{"emptyLinePlaceholder":190},[59,2854,2855,2857,2859,2862,2864,2866,2868,2870],{"class":61,"line":154},[59,2856,85],{"class":84},[59,2858,88],{"class":84},[59,2860,2861],{"class":91}," b_touches_a",[59,2863,96],{"class":95},[59,2865,99],{"class":84},[59,2867,102],{"class":95},[59,2869,105],{"class":84},[59,2871,108],{"class":95},[59,2873,2874],{"class":61,"line":166},[59,2875,114],{"class":84},[59,2877,2878,2880,2883,2885,2887,2889,2891,2893,2895,2898,2900,2902,2904,2907,2909],{"class":61,"line":175},[59,2879,2794],{"class":84},[59,2881,2882],{"class":95}," a ",[59,2884,2197],{"class":84},[59,2886,2802],{"class":95},[59,2888,1819],{"class":84},[59,2890,145],{"class":84},[59,2892,96],{"class":95},[59,2894,2811],{"class":84},[59,2896,2897],{"class":95}," id ",[59,2899,1819],{"class":84},[59,2901,2311],{"class":132},[59,2903,136],{"class":95},[59,2905,2906],{"class":132},"a_id",[59,2908,2825],{"class":95},[59,2910,2911],{"class":65},"-- triggers a_touches_b\n",[59,2913,2914,2916],{"class":61,"line":187},[59,2915,157],{"class":84},[59,2917,2418],{"class":95},[59,2919,2920,2922],{"class":61,"line":194},[59,2921,169],{"class":84},[59,2923,172],{"class":95},[59,2925,2926,2928,2930],{"class":61,"line":200},[59,2927,178],{"class":95},[59,2929,181],{"class":84},[59,2931,184],{"class":95},[59,2933,2934],{"class":61,"line":206},[59,2935,191],{"emptyLinePlaceholder":190},[59,2937,2938,2940,2942,2945,2948,2950,2952,2954,2957,2959,2961,2963,2965],{"class":61,"line":217},[59,2939,85],{"class":84},[59,2941,211],{"class":84},[59,2943,2944],{"class":91}," a_sync",[59,2946,2947],{"class":84}," AFTER",[59,2949,223],{"class":84},[59,2951,226],{"class":84},[59,2953,2882],{"class":95},[59,2955,2956],{"class":84},"FOR",[59,2958,241],{"class":95},[59,2960,244],{"class":84},[59,2962,1043],{"class":84},[59,2964,88],{"class":84},[59,2966,2967],{"class":95}," a_touches_b();\n",[59,2969,2970,2972,2974,2977,2979,2981,2983,2985,2987,2989,2991,2993,2995],{"class":61,"line":235},[59,2971,85],{"class":84},[59,2973,211],{"class":84},[59,2975,2976],{"class":91}," b_sync",[59,2978,2947],{"class":84},[59,2980,223],{"class":84},[59,2982,226],{"class":84},[59,2984,2797],{"class":95},[59,2986,2956],{"class":84},[59,2988,241],{"class":95},[59,2990,244],{"class":84},[59,2992,1043],{"class":84},[59,2994,88],{"class":84},[59,2996,2997],{"class":95}," b_touches_a();\n",[59,2999,3000],{"class":61,"line":250},[59,3001,191],{"emptyLinePlaceholder":190},[59,3003,3004],{"class":61,"line":261},[59,3005,3006],{"class":65},"-- UPDATE a SET x = 1 WHERE id = 1;\n",[59,3008,3009],{"class":61,"line":267},[59,3010,3011],{"class":65},"-- → a_sync fires → UPDATE b → b_sync fires → UPDATE a → a_sync fires → ...\n",[59,3013,3014],{"class":61,"line":273},[59,3015,3016],{"class":65},"-- PostgreSQL has a stack depth limit → ERROR: stack depth limit exceeded.\n",[59,3018,3019],{"class":61,"line":1034},[59,3020,3021],{"class":65},"-- Even without the stack limit, this is an infinite loop.\n",[59,3023,3024],{"class":61,"line":1800},[59,3025,191],{"emptyLinePlaceholder":190},[59,3027,3028],{"class":61,"line":1805},[59,3029,3030],{"class":65},"-- ✅ Fix: use a session variable to break the cycle, or restructure so\n",[59,3032,3033],{"class":61,"line":1811},[59,3034,3035],{"class":65},"-- only one direction has a trigger, or use a flag column to avoid\n",[59,3037,3038],{"class":61,"line":1828},[59,3039,3040],{"class":65},"-- re-triggering.\n",[59,3042,3043,3045,3047,3049,3051,3053,3055,3057],{"class":61,"line":1837},[59,3044,85],{"class":84},[59,3046,88],{"class":84},[59,3048,2775],{"class":91},[59,3050,96],{"class":95},[59,3052,99],{"class":84},[59,3054,102],{"class":95},[59,3056,105],{"class":84},[59,3058,108],{"class":95},[59,3060,3061],{"class":61,"line":1851},[59,3062,114],{"class":84},[59,3064,3065,3067,3070,3073,3076,3079,3081],{"class":61,"line":1863},[59,3066,1814],{"class":84},[59,3068,3069],{"class":95}," current_setting(",[59,3071,3072],{"class":888},"'app.suppress_b_sync'",[59,3074,3075],{"class":95},", true) ",[59,3077,3078],{"class":84},"IS",[59,3080,1268],{"class":84},[59,3082,1825],{"class":84},[59,3084,3085,3088,3090,3092,3095,3098],{"class":61,"line":1868},[59,3086,3087],{"class":95},"    PERFORM set_config(",[59,3089,3072],{"class":888},[59,3091,856],{"class":95},[59,3093,3094],{"class":888},"'1'",[59,3096,3097],{"class":95},", true);  ",[59,3099,3100],{"class":65},"-- local to txn\n",[59,3102,3103,3106,3108,3110,3112,3114,3116,3118,3120,3122,3124,3126,3128,3130],{"class":61,"line":1874},[59,3104,3105],{"class":84},"    UPDATE",[59,3107,2797],{"class":95},[59,3109,2197],{"class":84},[59,3111,2802],{"class":95},[59,3113,1819],{"class":84},[59,3115,145],{"class":84},[59,3117,96],{"class":95},[59,3119,2811],{"class":84},[59,3121,2814],{"class":95},[59,3123,1819],{"class":84},[59,3125,2311],{"class":132},[59,3127,136],{"class":95},[59,3129,853],{"class":132},[59,3131,172],{"class":95},[59,3133,3134,3136,3138,3140,3142],{"class":61,"line":1887},[59,3135,3087],{"class":95},[59,3137,3072],{"class":888},[59,3139,856],{"class":95},[59,3141,367],{"class":84},[59,3143,3144],{"class":95},", true);\n",[59,3146,3147,3149,3151],{"class":61,"line":1895},[59,3148,2059],{"class":84},[59,3150,2062],{"class":84},[59,3152,172],{"class":95},[59,3154,3155,3157],{"class":61,"line":1906},[59,3156,157],{"class":84},[59,3158,2418],{"class":95},[59,3160,3161,3163],{"class":61,"line":1917},[59,3162,169],{"class":84},[59,3164,172],{"class":95},[59,3166,3167,3169,3171],{"class":61,"line":1922},[59,3168,178],{"class":95},[59,3170,181],{"class":84},[59,3172,184],{"class":95},[42,3174,3176],{"id":3175},"disable-trigger-for-migrations","DISABLE TRIGGER for Migrations",[47,3178,3179],{"language":49},[51,3180,3182],{"className":53,"code":3181,"language":49,"meta":55,"style":55},"-- Disable triggers for bulk loads \u002F migrations (avoids per-row function\n-- calls on COPY \u002F bulk INSERT). Bypasses trigger logic entirely.\nALTER TABLE orders DISABLE TRIGGER ALL;      -- ALL: user + FK triggers\n-- WARNING: DISABLE TRIGGER ALL also disables FK enforcement.\n-- For FK-safe bulk loads, use DISABLE TRIGGER USER (keeps FK triggers).\nALTER TABLE orders DISABLE TRIGGER USER;     -- only user-defined triggers\nCOPY orders FROM '\u002Fdata\u002Forders.csv' WITH CSV HEADER;\nALTER TABLE orders ENABLE TRIGGER USER;      -- re-enable\n\n-- Replica trigger modes:\n-- ENABLE REPLICA: fires only on replica (logical replication target)\n-- ENABLE ALWAYS:  fires on primary AND replica (use with caution)\nALTER TABLE orders ENABLE ALWAYS TRIGGER orders_audit_batch;\n",[27,3183,3184,3189,3194,3212,3217,3222,3238,3256,3273,3277,3282,3287,3292],{"__ignoreMap":55},[59,3185,3186],{"class":61,"line":62},[59,3187,3188],{"class":65},"-- Disable triggers for bulk loads \u002F migrations (avoids per-row function\n",[59,3190,3191],{"class":61,"line":69},[59,3192,3193],{"class":65},"-- calls on COPY \u002F bulk INSERT). Bypasses trigger logic entirely.\n",[59,3195,3196,3199,3201,3203,3206,3209],{"class":61,"line":75},[59,3197,3198],{"class":84},"ALTER",[59,3200,1536],{"class":84},[59,3202,2194],{"class":95},[59,3204,3205],{"class":84},"DISABLE",[59,3207,3208],{"class":95}," TRIGGER ALL;      ",[59,3210,3211],{"class":65},"-- ALL: user + FK triggers\n",[59,3213,3214],{"class":61,"line":81},[59,3215,3216],{"class":65},"-- WARNING: DISABLE TRIGGER ALL also disables FK enforcement.\n",[59,3218,3219],{"class":61,"line":111},[59,3220,3221],{"class":65},"-- For FK-safe bulk loads, use DISABLE TRIGGER USER (keeps FK triggers).\n",[59,3223,3224,3226,3228,3230,3232,3235],{"class":61,"line":117},[59,3225,3198],{"class":84},[59,3227,1536],{"class":84},[59,3229,2194],{"class":95},[59,3231,3205],{"class":84},[59,3233,3234],{"class":95}," TRIGGER USER;     ",[59,3236,3237],{"class":65},"-- only user-defined triggers\n",[59,3239,3240,3242,3244,3247,3250,3253],{"class":61,"line":123},[59,3241,2508],{"class":84},[59,3243,2194],{"class":95},[59,3245,3246],{"class":84},"FROM",[59,3248,3249],{"class":888}," '\u002Fdata\u002Forders.csv'",[59,3251,3252],{"class":84}," WITH",[59,3254,3255],{"class":95}," CSV HEADER;\n",[59,3257,3258,3260,3262,3264,3267,3270],{"class":61,"line":129},[59,3259,3198],{"class":84},[59,3261,1536],{"class":84},[59,3263,2194],{"class":95},[59,3265,3266],{"class":84},"ENABLE",[59,3268,3269],{"class":95}," TRIGGER USER;      ",[59,3271,3272],{"class":65},"-- re-enable\n",[59,3274,3275],{"class":61,"line":154},[59,3276,191],{"emptyLinePlaceholder":190},[59,3278,3279],{"class":61,"line":166},[59,3280,3281],{"class":65},"-- Replica trigger modes:\n",[59,3283,3284],{"class":61,"line":175},[59,3285,3286],{"class":65},"-- ENABLE REPLICA: fires only on replica (logical replication target)\n",[59,3288,3289],{"class":61,"line":187},[59,3290,3291],{"class":65},"-- ENABLE ALWAYS:  fires on primary AND replica (use with caution)\n",[59,3293,3294,3296,3298,3300,3302,3304],{"class":61,"line":194},[59,3295,3198],{"class":84},[59,3297,1536],{"class":84},[59,3299,2194],{"class":95},[59,3301,3266],{"class":84},[59,3303,1555],{"class":84},[59,3305,3306],{"class":95}," TRIGGER orders_audit_batch;\n",[42,3308,3310],{"id":3309},"inspecting-triggers","Inspecting Triggers",[47,3312,3313],{"language":49},[51,3314,3316],{"className":53,"code":3315,"language":49,"meta":55,"style":55},"-- List all triggers on a table with their definitions\nSELECT tgname,                          -- trigger name (controls fire order!)\n       tgenabled,                       -- O=origin, D=disabled, R=replica, A=always\n       pg_get_triggerdef(oid)           -- full CREATE TRIGGER statement\nFROM pg_trigger\nWHERE tgrelid = 'orders'::regclass      -- cast table name to regclass\n  AND NOT tgisinternal;                 -- exclude internally-created triggers (FKs)\nORDER BY tgname;                        -- alphabetical = fire order\n",[27,3317,3318,3323,3334,3342,3356,3363,3381,3394],{"__ignoreMap":55},[59,3319,3320],{"class":61,"line":62},[59,3321,3322],{"class":65},"-- List all triggers on a table with their definitions\n",[59,3324,3325,3328,3331],{"class":61,"line":69},[59,3326,3327],{"class":84},"SELECT",[59,3329,3330],{"class":95}," tgname,                          ",[59,3332,3333],{"class":65},"-- trigger name (controls fire order!)\n",[59,3335,3336,3339],{"class":61,"line":75},[59,3337,3338],{"class":95},"       tgenabled,                       ",[59,3340,3341],{"class":65},"-- O=origin, D=disabled, R=replica, A=always\n",[59,3343,3344,3347,3350,3353],{"class":61,"line":81},[59,3345,3346],{"class":95},"       pg_get_triggerdef(",[59,3348,3349],{"class":84},"oid",[59,3351,3352],{"class":95},")           ",[59,3354,3355],{"class":65},"-- full CREATE TRIGGER statement\n",[59,3357,3358,3360],{"class":61,"line":111},[59,3359,3246],{"class":84},[59,3361,3362],{"class":95}," pg_trigger\n",[59,3364,3365,3367,3370,3372,3375,3378],{"class":61,"line":117},[59,3366,2811],{"class":84},[59,3368,3369],{"class":95}," tgrelid ",[59,3371,1819],{"class":84},[59,3373,3374],{"class":888}," 'orders'",[59,3376,3377],{"class":95},"::regclass      ",[59,3379,3380],{"class":65},"-- cast table name to regclass\n",[59,3382,3383,3386,3388,3391],{"class":61,"line":123},[59,3384,3385],{"class":84},"  AND",[59,3387,2326],{"class":84},[59,3389,3390],{"class":95}," tgisinternal;                 ",[59,3392,3393],{"class":65},"-- exclude internally-created triggers (FKs)\n",[59,3395,3396,3399,3402],{"class":61,"line":129},[59,3397,3398],{"class":84},"ORDER BY",[59,3400,3401],{"class":95}," tgname;                        ",[59,3403,3404],{"class":65},"-- alphabetical = fire order\n",[42,3406,3408],{"id":3407},"tips-tricks","💡 Tips & Tricks",[3410,3411,3412,3433,3451,3478,3492,3505,3525],"ul",{},[3413,3414,3415,3418,3419,3421,3422,3425,3426,3428,3429,3432],"li",{},[18,3416,3417],{},"Idiom",": use ",[27,3420,1513],{}," with transition tables (",[27,3423,3424],{},"REFERENCING NEW TABLE AS ...",") for bulk audit triggers — one function call per statement instead of per row. A million-row ",[27,3427,36],{}," fires a row trigger a million times; a statement trigger fires once with all changed rows in a temp table. The audit ",[27,3430,3431],{},"INSERT...SELECT"," is a single batch operation.",[3413,3434,3435,3437,3438,856,3441,856,3444,3447,3448,136],{},[18,3436,3417],{},": name triggers with a numeric prefix (",[27,3439,3440],{},"01_set_defaults",[27,3442,3443],{},"02_validate",[27,3445,3446],{},"03_audit",") to control firing order — PostgreSQL fires same-event triggers alphabetically, so a prefix makes the order explicit, stable, and visible in ",[27,3449,3450],{},"pg_trigger",[3413,3452,3453,3418,3455,3458,3459,3462,3463,3466,3467,3469,3470,3473,3474,3477],{},[18,3454,3417],{},[27,3456,3457],{},"WHEN (NEW.col IS DISTINCT FROM OLD.col)"," to skip no-op updates — ",[27,3460,3461],{},"UPDATE customers SET name = name"," still writes a new row version (MVCC) and fires the trigger; the ",[27,3464,3465],{},"WHEN"," clause avoids the function call and the spurious ",[27,3468,139],{}," bump. ",[27,3471,3472],{},"IS DISTINCT FROM"," is NULL-safe (unlike ",[27,3475,3476],{},"\u003C>",").",[3413,3479,3480,3418,3482,3484,3485,3488,3489,3491],{},[18,3481,3417],{},[27,3483,653],{}," to write a single multi-event trigger function (",[27,3486,3487],{},"AFTER INSERT OR UPDATE OR DELETE",") instead of three separate functions — one function, one trigger, all three events, branched by ",[27,3490,653],{},". Less code, one place to maintain.",[3413,3493,3494,3497,3498,3500,3501,3504],{},[18,3495,3496],{},"Debug",": when a column changes unexpectedly, query ",[27,3499,3450],{}," to list triggers on the table and inspect their functions — ",[27,3502,3503],{},"SELECT tgname, pg_get_triggerdef(oid) FROM pg_trigger WHERE tgrelid = 'orders'::regclass AND NOT tgisinternal ORDER BY tgname;",". The trigger hunt is the first step in \"why did this column change?\"",[3413,3506,3507,3418,3510,3513,3514,3517,3518,3521,3522,3524],{},[18,3508,3509],{},"Migration",[27,3511,3512],{},"ALTER TABLE x DISABLE TRIGGER USER"," (not ",[27,3515,3516],{},"ALL",") for bulk loads — ",[27,3519,3520],{},"USER"," disables only your triggers, keeping FK enforcement active. ",[27,3523,3516],{}," disables FK triggers too, risking orphaned rows.",[3413,3526,3527,3529,3530,3532,3533,3535,3536,3538,3539,3541,3542,3544,3545,3548],{},[18,3528,3417],{},": add a ",[27,3531,618],{}," trigger alongside your ",[27,3534,39],{}," audit trigger — ",[27,3537,618],{}," bypasses ",[27,3540,39],{}," triggers entirely, so without a ",[27,3543,618],{}," trigger, a ",[27,3546,3547],{},"TRUNCATE orders;"," wipes the table with zero audit trail. A common security blind spot.",[42,3550,3552],{"id":3551},"️-edge-cases-gotchas","⚠️ Edge Cases & Gotchas",[3410,3554,3555,3575,3588,3594,3610,3616,3632,3651,3674,3690,3714],{},[3413,3556,3557,3565,3566,3568,3569,3571,3572,3574],{},[18,3558,3559,3561,3562,3564],{},[27,3560,316],{}," trigger returning ",[27,3563,367],{}," silently drops the row",": no error, no log — the row just isn't inserted\u002Fupdated\u002Fdeleted. A common source of \"where did my row go?\" debugging. Always return ",[27,3567,322],{}," (or ",[27,3570,546],{}," for DELETE) from ",[27,3573,316],{}," triggers unless you deliberately want to skip.",[3413,3576,3577,3580,3581,856,3584,3587],{},[18,3578,3579],{},"Trigger firing order is alphabetical by name",": not definition order. Rename a trigger and the order changes. Use numeric prefixes (",[27,3582,3583],{},"01_",[27,3585,3586],{},"02_",") for explicit, stable ordering.",[3413,3589,3590,3593],{},[18,3591,3592],{},"Trigger recursion",": a trigger on A updates B, whose trigger updates A → infinite loop until stack depth limit. Avoid triggers that modify other tables with their own triggers, or use a session variable guard.",[3413,3595,3596,3599,3600,3602,3603,3605,3606,3609],{},[18,3597,3598],{},"Transition tables memory for large operations",": ",[27,3601,3424],{}," materializes ALL changed rows in a temp table. A 10M-row ",[27,3604,36],{}," with transition tables builds a 10M-row temp table in memory\u002Fdisk. For very large operations, use row-level triggers or ",[27,3607,3608],{},"DISABLE TRIGGER"," for the bulk load.",[3413,3611,3612,3615],{},[18,3613,3614],{},"Triggers on partitioned tables",": PostgreSQL 11+ allows triggers on partitioned tables that propagate to all partitions. Pre-11, you had to create triggers on each partition individually.",[3413,3617,3618,3599,3623,3625,3626,3628,3629,3631],{},[18,3619,3620,3622],{},[27,3621,3465],{}," clause evaluation cost",[27,3624,3465],{}," is evaluated per row before the function runs. A ",[27,3627,3465],{}," with a complex expression is cheaper than calling the function, but not free. For extremely hot tables, ensure ",[27,3630,3465],{}," references indexed columns or is a simple comparison.",[3413,3633,3634,3641,3642,3645,3646,33,3648,3650],{},[18,3635,3636,33,3638,3640],{},[27,3637,322],{},[27,3639,546],{}," in statement-level triggers",": not available — use transition tables (",[27,3643,3644],{},"REFERENCING","). Attempting to reference ",[27,3647,322],{},[27,3649,546],{}," in a statement-level trigger function raises an error.",[3413,3652,3653,3658,3659,3662,3663,3665,3666,33,3668,3670,3671,3673],{},[18,3654,3655,3657],{},[27,3656,618],{}," triggers are statement-level only",": no ",[27,3660,3661],{},"FOR EACH ROW"," on ",[27,3664,618],{},". ",[27,3667,322],{},[27,3669,546],{}," are NULL. Always ",[27,3672,339],{}," (the table is already truncated).",[3413,3675,3676,3682,3683,3685,3686,3689],{},[18,3677,3678,3679,3681],{},"Triggers fire on ",[27,3680,2508],{}," and bulk loads",": a trigger fires for every row loaded via ",[27,3684,2508],{},", which can be orders of magnitude slower than the load itself. ",[27,3687,3688],{},"DISABLE TRIGGER USER"," for bulk loads, re-enable after.",[3413,3691,3692,3599,3702,3704,3705,3707,3708,3710,3711,3713],{},[18,3693,3694,3696,3697,33,3699,3701],{},[27,3695,339],{}," triggers see other ",[27,3698,316],{},[27,3700,339],{}," triggers' changes",[27,3703,339],{}," triggers fire in alphabetical order and see the cumulative effect of all ",[27,3706,316],{}," triggers and earlier ",[27,3709,339],{}," triggers. This is why audit should be ",[27,3712,339],{}," (sees the final state).",[3413,3715,3716,3719,3720,3723,3724,3727,3728,3730],{},[18,3717,3718],{},"Replica trigger firing",": by default, triggers fire on the primary only. ",[27,3721,3722],{},"ENABLE REPLICA"," fires only on replicas; ",[27,3725,3726],{},"ENABLE ALWAYS"," fires on both. For logical replication where the target has its own triggers, ",[27,3729,3726],{}," can cause double-execution of side effects.",[42,3732,3734],{"id":3733},"spot-the-bug","🧠 Spot the Bug",[14,3736,3737,3738,3741,3742,3744,3745,3747,3748,3750,3751,3754],{},"A developer writes a ",[27,3739,3740],{},"BEFORE UPDATE"," trigger to set ",[27,3743,139],{},", but users report that ",[27,3746,139],{}," changes even when the ",[27,3749,36],{}," didn't change any data (",[27,3752,3753],{},"UPDATE customers SET name = name WHERE id = 1","). The trigger:",[47,3756,3757],{"language":49},[51,3758,3760],{"className":53,"code":3759,"language":49,"meta":55,"style":55},"CREATE FUNCTION update_modified_at() RETURNS trigger AS $$\nBEGIN\n  NEW.modified_at = now();\n  RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER touch_modified_at\n  BEFORE UPDATE ON customers\n  FOR EACH ROW\n  EXECUTE FUNCTION update_modified_at();\n",[27,3761,3762,3781,3785,3800,3806,3812,3820,3824,3832,3842,3850],{"__ignoreMap":55},[59,3763,3764,3766,3768,3771,3773,3775,3777,3779],{"class":61,"line":62},[59,3765,85],{"class":84},[59,3767,88],{"class":84},[59,3769,3770],{"class":91}," update_modified_at",[59,3772,96],{"class":95},[59,3774,99],{"class":84},[59,3776,102],{"class":95},[59,3778,105],{"class":84},[59,3780,108],{"class":95},[59,3782,3783],{"class":61,"line":69},[59,3784,114],{"class":84},[59,3786,3787,3789,3791,3793,3795,3797],{"class":61,"line":75},[59,3788,133],{"class":132},[59,3790,136],{"class":95},[59,3792,139],{"class":132},[59,3794,142],{"class":84},[59,3796,145],{"class":84},[59,3798,3799],{"class":95},"();\n",[59,3801,3802,3804],{"class":61,"line":81},[59,3803,157],{"class":84},[59,3805,2418],{"class":95},[59,3807,3808,3810],{"class":61,"line":111},[59,3809,169],{"class":84},[59,3811,172],{"class":95},[59,3813,3814,3816,3818],{"class":61,"line":117},[59,3815,178],{"class":95},[59,3817,181],{"class":84},[59,3819,184],{"class":95},[59,3821,3822],{"class":61,"line":123},[59,3823,191],{"emptyLinePlaceholder":190},[59,3825,3826,3828,3830],{"class":61,"line":129},[59,3827,85],{"class":84},[59,3829,211],{"class":84},[59,3831,214],{"class":91},[59,3833,3834,3836,3838,3840],{"class":61,"line":154},[59,3835,220],{"class":84},[59,3837,223],{"class":84},[59,3839,226],{"class":84},[59,3841,1103],{"class":95},[59,3843,3844,3846,3848],{"class":61,"line":166},[59,3845,238],{"class":84},[59,3847,241],{"class":95},[59,3849,428],{"class":84},[59,3851,3852,3854,3856],{"class":61,"line":175},[59,3853,253],{"class":84},[59,3855,88],{"class":84},[59,3857,3858],{"class":95}," update_modified_at();\n",[3860,3861,3862,3866,3890,3896,3956,3969],"details",{},[3863,3864,3865],"summary",{},"Answer",[14,3867,3868,3869,3871,3872,3874,3875,3877,3878,3880,3881,3883,3884,3886,3887,3889],{},"The trigger fires on every ",[27,3870,36],{}," that matches a row, regardless of whether the values changed. ",[27,3873,3753],{}," is still an ",[27,3876,36],{}," — PostgreSQL writes a new row version (MVCC semantics: an ",[27,3879,36],{}," is always a delete + insert under the hood), the ",[27,3882,3740],{}," trigger fires, and ",[27,3885,139],{}," is set to ",[27,3888,1177],{}," even though the data is identical.",[14,3891,3892,3893,3895],{},"The fix — add a ",[27,3894,3465],{}," clause that skips no-op updates:",[47,3897,3898],{"language":49},[51,3899,3901],{"className":53,"code":3900,"language":49,"meta":55,"style":55},"CREATE TRIGGER touch_modified_at\n  BEFORE UPDATE ON customers\n  FOR EACH ROW\n  WHEN (NEW IS DISTINCT FROM OLD)   -- NULL-safe: only fire if something actually changed\n  EXECUTE FUNCTION update_modified_at();\n",[27,3902,3903,3911,3921,3929,3948],{"__ignoreMap":55},[59,3904,3905,3907,3909],{"class":61,"line":62},[59,3906,85],{"class":84},[59,3908,211],{"class":84},[59,3910,214],{"class":91},[59,3912,3913,3915,3917,3919],{"class":61,"line":69},[59,3914,220],{"class":84},[59,3916,223],{"class":84},[59,3918,226],{"class":84},[59,3920,1103],{"class":95},[59,3922,3923,3925,3927],{"class":61,"line":75},[59,3924,238],{"class":84},[59,3926,241],{"class":95},[59,3928,428],{"class":84},[59,3930,3931,3933,3936,3938,3940,3942,3945],{"class":61,"line":81},[59,3932,1116],{"class":84},[59,3934,3935],{"class":95}," (NEW ",[59,3937,3078],{"class":84},[59,3939,1131],{"class":84},[59,3941,1134],{"class":84},[59,3943,3944],{"class":95}," OLD)   ",[59,3946,3947],{"class":65},"-- NULL-safe: only fire if something actually changed\n",[59,3949,3950,3952,3954],{"class":61,"line":111},[59,3951,253],{"class":84},[59,3953,88],{"class":84},[59,3955,3858],{"class":95},[14,3957,3958,3961,3962,3965,3966,3968],{},[27,3959,3960],{},"NEW IS DISTINCT FROM OLD"," is false when the rows are identical (NULL-safe, unlike ",[27,3963,3964],{},"NEW \u003C> OLD"," which is NULL if any column is NULL). The trigger skips no-op updates, ",[27,3967,139],{}," reflects actual modifications, and the function-call overhead is avoided.",[14,3970,3971,3599,3974,3976,3977,3980,3981,3983],{},[18,3972,3973],{},"The lesson",[27,3975,36],{}," fires the trigger even if no columns changed — PostgreSQL writes a new row version regardless. Use ",[27,3978,3979],{},"WHEN (NEW IS DISTINCT FROM OLD)"," to make ",[27,3982,139],{}," reflect actual modifications.",[3985,3986,3987],"style",{},"html pre.shiki code .sdCPZ, html code.shiki .sdCPZ{--shiki-default:#6A737D;--shiki-github-dark:#6A737D}html pre.shiki code .svdQ7, html code.shiki .svdQ7{--shiki-default:#D73A49;--shiki-github-dark:#F97583}html pre.shiki code .sIsaT, html code.shiki .sIsaT{--shiki-default:#6F42C1;--shiki-github-dark:#B392F0}html pre.shiki code .ssxIu, html code.shiki .ssxIu{--shiki-default:#24292E;--shiki-github-dark:#E1E4E8}html pre.shiki code .snvgF, html code.shiki .snvgF{--shiki-default:#005CC5;--shiki-github-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .github-dark .shiki span {color: var(--shiki-github-dark);background: var(--shiki-github-dark-bg);font-style: var(--shiki-github-dark-font-style);font-weight: var(--shiki-github-dark-font-weight);text-decoration: var(--shiki-github-dark-text-decoration);}html.github-dark .shiki span {color: var(--shiki-github-dark);background: var(--shiki-github-dark-bg);font-style: var(--shiki-github-dark-font-style);font-weight: var(--shiki-github-dark-font-weight);text-decoration: var(--shiki-github-dark-text-decoration);}html pre.shiki code .sJ6F3, html code.shiki .sJ6F3{--shiki-default:#032F62;--shiki-github-dark:#9ECBFF}",{"title":55,"searchDepth":69,"depth":69,"links":3989},[3990,3991,3992,3993,3994,3995,3996,3997,3998,3999,4000,4001,4002,4003,4004,4005,4006,4007],{"id":44,"depth":69,"text":45},{"id":279,"depth":69,"text":280},{"id":380,"depth":69,"text":381},{"id":527,"depth":69,"text":528},{"id":628,"depth":69,"text":629},{"id":760,"depth":69,"text":761},{"id":1051,"depth":69,"text":1052},{"id":1186,"depth":69,"text":1187},{"id":1350,"depth":69,"text":1351},{"id":1506,"depth":69,"text":1507},{"id":2238,"depth":69,"text":2239},{"id":2512,"depth":69,"text":2513},{"id":2753,"depth":69,"text":2754},{"id":3175,"depth":69,"text":3176},{"id":3309,"depth":69,"text":3310},{"id":3407,"depth":69,"text":3408},{"id":3551,"depth":69,"text":3552},{"id":3733,"depth":69,"text":3734},"Trigger anatomy (function + trigger), BEFORE\u002FAFTER semantics, FOR EACH ROW vs STATEMENT, transition tables, WHEN clauses, firing order, recursion — code-first reference with a production audit-log system and anti-patterns for hidden side effects.","md",{},"\u002Fsql\u002F21-triggers-and-events",{"title":5,"description":4008},"sql\u002F21-triggers-and-events","2awoPrhBW8xQg1khXiYCRV5PF8RtDgumK3YIuSE2rkc",1789924654877]