Statement Triggers in Postgres
Postgres can define a trigger for each row
, or for each statement
.
For a while, statement triggers were not used much (I’ve never seen one in the wild actually), because they couldn’t access the rows modified by the statement.
But in Postgres 10, they introduced something called “transition tables” which allow you to access the rows that were modified by the statement.
I’ll give a quick example, adapted from this article.
1create table books (2 id serial primary key not null,3 name text not null4);5
6create or replace function show_modified_tups() returns trigger as $$7declare8r1 record;9begin10if (tg_op = 'DELETE') then11 for r1 in select * from old12 loop13 raise notice 'old: %', r1;14 end loop;15elsif (tg_op = 'UPDATE') then16 for r1 in select * from new17 loop18 raise notice 'new: %', r1;19 end loop;20 for r1 in select * from old21 loop22 raise notice 'old: %', r1;23 end loop;24elsif (tg_op = 'INSERT') THEN25 for r1 in select * from new26 loop27 raise notice 'new: %', r1;28 end loop;29end if;30return new;31end;32$$ language plpgsql;33
34create trigger log_books_inserted35after insert on books36referencing new table as new37for each statement38execute function show_modified_tups ();39
40create trigger log_books_updated41after update on books42referencing new table as new old table as old43for each statement44execute function show_modified_tups ();45
46create trigger log_books_deleted47after delete on books48referencing old table as old49for each statement50execute function show_modified_tups ();
And testing it..
1insert into books (name) values2('The Great Gatsby'),3('The Catcher in the Rye'),4('Moonraker');5
6NOTICE: new: (1,"The Great Gatsby")7NOTICE: new: (2,"The Catcher in the Rye")8NOTICE: new: (3,Moonraker)
1update books2 set name = 'THE GREAT GATSBY'3where id = 1;4
5NOTICE: new: (1,"THE GREAT GATSBY")6NOTICE: old: (1,"The Great Gatsby")
1delete from books2where id = 2;3
4NOTICE: old: (2,"The Catcher in the Rye")
Additional Notes
- An operation that modifies zero rows will still result in the execution of a statement trigger.
Instead Of
triggers on views can only befor each row
.- Statement triggers can be called for
Truncate
statements. - The
when
condition is borderline useless in statement level triggers, because you can’t refer to any values in the table. - Transition relations are only available in
After
triggers that are notconstraint
triggers.- If the trigger is an
update
trigger, you can’t specify acolumn_name
list.
- If the trigger is an
This table from the Postgres documentation is a good summary of the differences between row and statement triggers:
When | Event | Row-level | Statement-level |
---|---|---|---|
Before | Insert / Update / Delete | Tables and foreign tables | Tables, views, and foreign tables |
Before | Truncate | — | Tables and foreign tables |
After | Insert / Update / Delete | Tables and foreign tables | Tables, views, and foreign tables |
After | Truncate | — | Tables and foreign tables |
Instead Of | Insert / Update / Delete | Views | — |
Instead Of | Truncate | — | — |
Revisiting The Pyramid Scheme
In my last blog post, I created an example of triggers killing performance and how to work around it.
Let’s see if statement triggers would have been a useful solution. The full example would look like this:
1drop table if exists members;2
3create table members (4 id serial primary key,5 name text not null,6 revenue numeric(10, 2) not null default 0,7 referred_revenue numeric(10, 2) not null default 0,8
9 referred_by integer references members (id)10);11
12create or replace function update_referred_revenue_on_insert() returns trigger as $$13begin14 update members15 set referred_revenue = referred_revenue + total_revenue16 from (17 select referred_by, sum(revenue) as total_revenue18 from new19 group by referred_by20 having referred_by is not null21 ) as new_referrals22 where members.id = new_referrals.referred_by;23
24 return new;25end;26$$ language plpgsql;27
28create trigger update_referred_revenue_on_insert29after insert on members30referencing new table as new31execute function update_referred_revenue_on_insert ();32
33create or replace function update_referred_revenue_on_update() returns trigger as $$34declare35 count_updated integer;36begin37 select count(*) into count_updated38 from new;39
40 if count_updated = 0 then41 return new;42 end if;43
44 update members45 set referred_revenue = referred_revenue + new_referrals.new_total_revenue46 from (47 select48 n.referred_by,49 sum(n.revenue - o.revenue) + sum(n.referred_revenue - o.referred_revenue) as new_total_revenue50 from new n51 join old o on n.id = o.id52 group by n.referred_by53 having n.referred_by is not null54 ) as new_referrals55 where members.id = new_referrals.referred_by;56
57 return new;58end;59$$ language plpgsql;60
61create trigger update_referred_revenue_on_update62after update on members63referencing new table as new old table as old64execute function update_referred_revenue_on_update ();
And then create a few members:
1insert into members (name, revenue) values ('me', 100);2insert into members (name, revenue, referred_by) values ('Stella', 100, 1);3insert into members (name, revenue, referred_by) values ('Cornelius', 100, 1);4insert into members (name, revenue, referred_by) values ('Rita', 100, 2);5insert into members (name, revenue, referred_by) values ('Conrad', 100, 2);6insert into members (name, revenue, referred_by) values ('Helmut', 100, 3);7insert into members (name, revenue, referred_by) values ('Manfried', 100, 3);
We need to make sure we can handle bulk queries - where multiple rows affect a single referrer. Let’s test that.
1insert into members (name, revenue, referred_by)2select 'member' || i::text, 10, 33from generate_series(1, 5) as i;
Let’s take a look at the data:
Name | Revenue | Referred Revenue |
---|---|---|
me | 100.00 | 650.00 |
Stella | 100.00 | 200.00 |
Cornelius | 100.00 | 250.00 |
Rita | 100.00 | 0.00 |
Conrad | 100.00 | 0.00 |
Helmut | 100.00 | 0.00 |
Manfried | 100.00 | 0.00 |
member1 | 10.00 | 0.00 |
member2 | 10.00 | 0.00 |
member3 | 10.00 | 0.00 |
member4 | 10.00 | 0.00 |
member5 | 10.00 | 0.00 |
Then let’s do another bulk update:
1update members2 set revenue = 03where id > 7;
Name | Revenue | Referred Revenue |
---|---|---|
me | 100.00 | 600.00 |
Stella | 100.00 | 200.00 |
Cornelius | 100.00 | 200.00 |
Rita | 100.00 | 0.00 |
Conrad | 100.00 | 0.00 |
Helmut | 100.00 | 0.00 |
Manfried | 100.00 | 0.00 |
member1 | 0.00 | 0.00 |
member2 | 0.00 | 0.00 |
member3 | 0.00 | 0.00 |
member4 | 0.00 | 0.00 |
member5 | 0.00 | 0.00 |
Lastly, let’s add a ton of new members, to show that our new triggers can handle it performance-wise.
1insert into members (name, revenue, referred_by)2select 'member' || i::text, 10, 33from generate_series(1, 1000000) as i;
That takes ~6s, which is basically what the insert with no trigger takes.
What makes this complicated?
There are a couple new challenges.
The actual update calls need to handle the case where multiple new
rows affect the same referrer. This is why we’re doing group by
in the update
statements.
e.g.
1update members2 set referred_revenue = referred_revenue + total_revenue3from (4 select referred_by, sum(revenue) as total_revenue5 from new6 group by referred_by7 having referred_by is not null8) as new_referrals9where members.id = new_referrals.referred_by;10
11-- and12update members13 set referred_revenue = referred_revenue + new_referrals.new_total_revenue14from (15 select16 n.referred_by,17 sum(n.revenue - o.revenue) + sum(n.referred_revenue - o.referred_revenue) as new_total_revenue18 from new n19 join old o on n.id = o.id20 group by n.referred_by21 having n.referred_by is not null22) as new_referrals23where members.id = new_referrals.referred_by;
Furthermore, the update
trigger is susceptible to an infinite loop, because it will get called even if no rows are updated. This is why we’re checking count_updated
in the update
trigger.
1select count(*) into count_updated2from new;3
4if count_updated = 0 then5 return new;6end if;
Conclusion
In summary, statement triggers can be more performant than row level triggers. They’re likely an underused feature in Postgres.
Wow! You read the whole thing. People who make it this far sometimes
want to receive emails when I post something new.
I also have an RSS feed.