CPU Cost Parameters and Function COST Attributes #
A query filters on a cheap integer comparison and on is_eligible(customer_id), a PL/pgSQL function that runs a lookup of its own. The plan evaluates the function first, for every row, and only then applies the integer test that would have discarded 99% of them. Nothing is wrong with the SQL: the planner simply believes the function is as cheap as the comparison, because nobody told it otherwise.
Page costs get most of the attention in the cost estimation model, but three CPU parameters and two per-function attributes decide a surprising number of plan details: which filter runs first, whether an expression is worth an index, and how many rows a set-returning function is assumed to produce.
The Cost-Model Condition #
The CPU side of the model has three global parameters, all relative to seq_page_cost = 1.0:
| Parameter | Default | Charged for |
|---|---|---|
cpu_tuple_cost |
0.01 | each row processed by a node |
cpu_index_tuple_cost |
0.005 | each index entry processed |
cpu_operator_cost |
0.0025 | each operator or function call evaluated per row |
The third one is scaled per function. Every function has a COST attribute, expressed in units of cpu_operator_cost. Built-in and C-language functions default to COST 1; functions written in SQL, PL/pgSQL or other procedural languages default to COST 100. Operators inherit the cost of the function that implements them. A predicate calling a function with COST 10000 is therefore charged 10000 × 0.0025 = 25 cost units per row — the same as reading 25 pages.
That per-row price feeds three decisions:
- Qual ordering. Within a single scan’s
Filter, the planner sorts restriction clauses by estimated cost so cheaper tests run first and expensive ones see fewer rows. A misdeclared expensive function sorts to the front. - Access path choice. A high per-row filter cost makes plans that produce fewer candidate rows — index scans, or index conditions that satisfy part of the predicate — more attractive.
- Expression pushdown and caching decisions, such as whether a generic plan is cheaper overall.
Set-returning functions have a second attribute, ROWS, which defaults to 1000. A function in FROM returning 5 rows is estimated at 1000, which inflates every join above it — a row estimate explosion with a one-line fix.
Annotated EXPLAIN Evidence #
The function’s cost is visible only indirectly — through filter order in VERBOSE output and through total cost:
CREATE FUNCTION is_eligible(bigint) RETURNS boolean
LANGUAGE plpgsql STABLE COST 1 AS $$
BEGIN
RETURN EXISTS (SELECT 1 FROM eligibility WHERE customer_id = $1 AND active);
END $$;
EXPLAIN (ANALYZE, VERBOSE)
SELECT id FROM orders WHERE is_eligible(customer_id) AND region_id = 4;
Seq Scan on public.orders (cost=0.00..224120.00 rows=4100 width=8)
(actual time=0.09..48210.4 rows=3920 loops=1)
Output: id
Filter: (is_eligible(orders.customer_id) AND (orders.region_id = 4))
Rows Removed by Filter: 8196080
Execution Time: 48211.6 ms
-- function listed first in Filter → evaluated first, 8.2M times
-- COST 1 told the planner it was as cheap as an integer comparison
After declaring a realistic cost:
ALTER FUNCTION is_eligible(bigint) COST 10000;
Seq Scan on public.orders (cost=0.00..1238120.00 rows=4100 width=8)
(actual time=0.07..1410.2 rows=3920 loops=1)
Output: id
Filter: ((orders.region_id = 4) AND is_eligible(orders.customer_id))
Rows Removed by Filter: 8196080
Execution Time: 1410.9 ms
-- region_id test now first; the function runs only for ~82,000 region-4 rows
-- total cost rose sharply, which also makes an index on region_id far more attractive
Step-by-Step Resolution #
-
List the functions in the slow predicate and their declared costs.
SELECT p.proname, l.lanname, p.procost, p.prorows, p.provolatile FROM pg_proc p JOIN pg_language l ON l.oid = p.prolang WHERE p.proname IN ('is_eligible', 'expand_tags'); -
Measure a function’s real per-call time in isolation:
EXPLAIN (ANALYZE) SELECT is_eligible(customer_id) FROM orders LIMIT 10000; -- divide the function-attributable time by 10000 calls -
Convert to cost units. Compare with a known operator: if the function takes roughly 1,000 times longer than an integer comparison,
COST 1000is a fair order of magnitude. Precision is unnecessary; the order of magnitude drives the decisions. -
Declare it.
ALTER FUNCTION is_eligible(bigint) COST 10000; ALTER FUNCTION expand_tags(text) ROWS 5; -- set-returning function, typically 5 rows -
Consider replacing the function call with a join. A per-row lookup function hides its query from the planner entirely; rewriting it as
EXISTSor a join lets the planner see semi-join options and statistics instead of a guessed cost. -
Leave the global CPU parameters alone unless many plans show the same distortion. They are relative weights, and changing them shifts every plan on the server at once.
Before and After #
-- BEFORE: is_eligible declared COST 1
Filter: (is_eligible(orders.customer_id) AND (orders.region_id = 4)) Execution Time: 48211.6 ms
-- AFTER: ALTER FUNCTION is_eligible(bigint) COST 10000
Filter: ((orders.region_id = 4) AND is_eligible(orders.customer_id)) Execution Time: 1410.9 ms
Why the function was declared COST 1 #
PL/pgSQL functions default to COST 100, so a COST 1 declaration was an explicit choice — usually copied from a template or written when the function was a simple expression that later grew a query inside it. The attribute is never revisited automatically, and nothing warns when a function’s real cost drifts away from its declaration. Search your schema for procedural-language functions declared below the default:
SELECT n.nspname, p.proname, p.procost
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
JOIN pg_language l ON l.oid = p.prolang
WHERE l.lanname IN ('plpgsql', 'sql') AND p.procost < 100
AND n.nspname NOT IN ('pg_catalog', 'information_schema');
The same audit is worth running for prorows on set-returning functions. Many return a handful of rows but keep the default estimate of 1000, and every join against them inherits a thousand-fold overestimate. SQL-language set-returning functions that are simple enough to be inlined avoid the problem entirely, because the planner plans their body instead of treating them as opaque.
Common Pitfalls #
Blaming the planner for filter order. The order follows declared costs. Diagnostic signal: an expensive function listed first in Filter under VERBOSE. Fix: declare an honest COST.
Tuning cpu_operator_cost globally for one function. It rescales every operator in every plan. Diagnostic signal: widespread plan changes after a config edit. Fix: change the one function’s COST.
Ignoring ROWS on set-returning functions. The default of 1000 distorts joins above the function. Diagnostic signal: Function Scan … rows=1000 with actual rows in single digits. Fix: ALTER FUNCTION … ROWS n, or a planner support function for functions whose output size varies.
Hiding queries inside functions. A function that runs a query per row can never be planned as a join. Diagnostic signal: a filter function whose runtime is dominated by lookups. Fix: express the lookup in SQL as EXISTS or a join.
Frequently Asked Questions #
What is the default COST of a function in PostgreSQL? #
Functions implemented in C and internal built-ins default to COST 1. Functions in SQL, PL/pgSQL and other procedural languages default to COST 100. The value is in units of cpu_operator_cost per call.
Does function COST affect the order filters run in? #
Yes. Within a scan’s filter, the planner orders restriction clauses by estimated per-row cost, so cheaper clauses run first. An under-declared expensive function can be evaluated before cheap tests that would have eliminated most rows.
What does the ROWS attribute of a function do? #
For set-returning functions it sets the estimated number of rows per call, 1000 by default. The estimate feeds joins and aggregates above the function scan, so a wrong value distorts the whole plan above it.
Related #
- Cost Estimation Models — parent guide: the full cost model
- How PostgreSQL Calculates Node Costs — sibling: page and tuple arithmetic per node
- Security Barrier Views and Leakproof Functions — the other attribute that decides where a function may run