Running EXPLAIN ANALYZE on functions
EXPLAIN ANALYZE on a function call hides the queries inside it. How to see the real plans of plpgsql and SQL functions on SupaHost, with PREPARE and auto_explain.
You call a slow database function, run EXPLAIN ANALYZE on it, and get back a plan that says almost nothing.
What you see
explain analyze select public.invoice_totals('7c1e0a52-3f4b-4d61-9a0e-2a8f5f0c9b11');
Result (cost=0.00..0.26 rows=1 width=32) (actual time=1843.201..1843.202 rows=1 loops=1)
Planning Time: 0.031 ms
Execution Time: 1843.230 ms
For a set-returning function you may see Function Scan on invoice_totals instead. Either way, the total time is there but the queries that caused it are not.
Why it happens
EXPLAIN plans the statement you give it. For a plpgsql function, that statement is just "call this function". The queries inside the function body are planned separately when the function runs, so they never show up in the outer plan.
Simple language sql functions are different: when one is used in FROM and meets Postgres's inlining rules (a single select, not strict, not security definer, no set clause, declared stable or immutable), the planner folds its body into the outer query and EXPLAIN shows the real plan.
How to fix it
Option 1: plan the inner query yourself. This works in every SQL client, including Studio's SQL editor.
- Copy the slow query out of the function body.
- Turn the function parameters into a prepared statement, so Postgres plans it the way the function does:
prepare totals(uuid) as
select date_trunc('month', issued_at) as month, sum(amount_cents)
from public.invoices
where account_id = $1
group by 1;
explain (analyze, buffers) execute totals('7c1e0a52-3f4b-4d61-9a0e-2a8f5f0c9b11');
deallocate totals;
- plpgsql caches plans, and after five calls it may switch to a generic plan that ignores the actual parameter value. To see that plan too:
set plan_cache_mode = force_generic_plan;
prepare totals(uuid) as select ...; -- same query as above
explain (analyze, buffers) execute totals('7c1e0a52-3f4b-4d61-9a0e-2a8f5f0c9b11');
reset plan_cache_mode;
If the generic plan is much slower, the usual fix is an index that suits every value, or building that one query with execute format(...) so it's planned per call.
Option 2: auto_explain with nested statements. The auto_explain module can print the plan of every statement a function runs. Try it in one session:
set auto_explain.log_min_duration = 0;
set auto_explain.log_analyze = on;
set auto_explain.log_nested_statements = on;
set auto_explain.log_level = notice;
select public.invoice_totals('7c1e0a52-3f4b-4d61-9a0e-2a8f5f0c9b11');
log_level = notice sends the plans to your client as notices instead of to the server log. If you get "permission denied to set parameter", or no plans appear, your role can't use auto_explain in this session; use option 1. Not every SQL client displays notices. psql does.
On SupaHost
- The server log isn't something you can browse: there's no log explorer, and Postgres only logs fatal errors. That's why option 2 sends plans to the client.
- On Business, psql shows notices:
sudo supahost-psql app-oneover SSH. See SSH access and direct Postgres. - When a function is called through the Data API (
supabase.rpc()), it runs asanonorauthenticatedwith RLS. To time it the same way, test insideset local role authenticatedas shown in RLS performance best practices.