From 0670198421dbd9bb0d5e3112ea1ebbc3ff90b96f Mon Sep 17 00:00:00 2001 From: sidgaikwad Date: Fri, 18 Sep 2026 01:08:56 +0530 Subject: [PATCH] docs(0011): document the SQL function inlining trade-off PostgreSQL will not inline a `language sql` function that carries a `SET` clause, so the remediation this lint recommends turns an inlinable function into a `Function Scan`: the function runs to completion and the caller filters afterwards, instead of the caller's predicate reaching the index. On a 200,000-row table with an index on the filtered column, and two functions whose bodies are identical and fully qualified, the pinned call reads ~8.6x the shared buffers (203 -> 1748) and spills 1,219 temporary blocks the inlined plan never touches. The gap widens with table size. It is the presence of a `SET` clause rather than the empty value, so no formulation of the fix avoids it. Document when this is worth caring about, which is narrower than it first looks: PL/pgSQL functions are never inlined, so pinning costs them nothing, and a SQL function outside a hot path will not notice. For an argument-less SQL function that does rely on predicate pushdown, a view gives the same plan and does not depend on the caller's search path at all -- with the two caveats that a view cannot take arguments and defaults to the owner's privileges. Addresses supabase/supabase#33131 --- docs/0011_function_search_path_mutable.md | 76 +++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/docs/0011_function_search_path_mutable.md b/docs/0011_function_search_path_mutable.md index 30e388c..1633180 100644 --- a/docs/0011_function_search_path_mutable.md +++ b/docs/0011_function_search_path_mutable.md @@ -50,3 +50,79 @@ $$; ``` Remember that once you set the `search_path = ''` all references to tables/functions/views/etc in your function's body must be qualified with a schema name. + +### Effect on SQL Function Inlining + +Pinning the search path has a cost that is worth knowing about before applying it everywhere: PostgreSQL will not inline a `language sql` function that carries a `SET` clause. Inlining is what lets the planner fold a simple SQL function into the calling query and push the caller's `where` clause down into it. Once the function is pinned, the call becomes a `Function Scan`, the function runs to completion, and the caller filters the result afterwards. + +Given a table with 200,000 rows and an index on `tenant`, and two functions whose bodies are identical and fully qualified: + +```sql +create function inline_test.all_items() + returns setof inline_test.items + language sql + stable +as $$ + select * from inline_test.items; +$$; + +create function inline_test.all_items_pinned() + returns setof inline_test.items + language sql + stable + set search_path = '' +as $$ + select * from inline_test.items; +$$; +``` + +Calling the unpinned function, the filter reaches the index: + +``` +Bitmap Heap Scan on items (actual time=0.060..0.423 rows=200 loops=1) + Recheck Cond: (tenant = 'tenant_42'::text) + Buffers: shared hit=203 + -> Bitmap Index Scan on items_tenant_idx (actual time=0.049..0.049 rows=200 loops=1) + Index Cond: (tenant = 'tenant_42'::text) +``` + +Calling the pinned one, it does not: + +``` +Function Scan on all_items_pinned (actual time=23.577..32.990 rows=200 loops=1) + Filter: (tenant = 'tenant_42'::text) + Rows Removed by Filter: 199800 + Buffers: shared hit=1748, temp read=1219 written=1219 +``` + +The pinned call reads roughly eight times as many shared buffers and spills 1,219 temporary blocks that the inlined plan never touches, and the gap widens as the table grows. + +This is caused by the presence of a `SET` clause, not by the empty value, so `set search_path = 'some_schema'` loses inlining in exactly the same way. There is no formulation of this fix that keeps it. + +### When This Matters + +For most functions it does not: + +- **`language plpgsql` functions are unaffected.** PostgreSQL never inlines them, so pinning the search path costs nothing. If your function is written in PL/pgSQL, apply the fix and move on. +- **`language sql` functions that are not on a hot path** pay a cost nobody will notice. + +It matters for a `language sql` function that is called inside a larger query and relies on the planner pushing predicates into it. + +If that function takes no arguments, a view is usually the better shape. It produces the same plan as the inlined function, and because a view stores resolved references rather than names, it does not depend on the caller's search path at all: + +```sql +create view inline_test.all_items_view as + select * from inline_test.items; +``` + +``` +Bitmap Heap Scan on items (actual time=0.047..2.137 rows=200 loops=1) + Recheck Cond: (tenant = 'tenant_42'::text) + Buffers: shared hit=203 + -> Bitmap Index Scan on items_tenant_idx + Index Cond: (tenant = 'tenant_42'::text) +``` + +Two things to check before swapping a function for a view. A view is not a drop-in replacement for a function that takes arguments; if you need parameters, either keep the function and accept the cost, or expose a view and let callers supply their own `where` clause. And a view runs with the privileges of its owner unless it is created with `security_invoker = true`, so confirm that matches what the function was doing before you make the change (see [security_definer_view](0010_security_definer_view.md)). + +Measure before rewriting anything. The plan is the thing to check: if the call shows up as a `Function Scan` with a large `Rows Removed by Filter`, inlining was lost and the function is a candidate for becoming a view.