A report written for Engineering is useful exactly once. The next week Marketing asks for the same numbers, so you copy the query, hunt down every place that says department_id = 1, and change them to 2. Miss one and the report quietly mixes two departments, and because each copy looks right on its own, nobody notices until a total is wrong in a meeting.
Parameterizing a report means arranging it so the input, the department, the date, the threshold, appears in one place and the rest of the query never spells it out. Change that one value and the whole report re-runs for a different input, with nothing to copy and nothing to drift. SQL has no function-call syntax inside a query, but it has something close enough: the WITH clause, which lets you name a calculation once and reference it anywhere below.
flowchart LR A["copy 1<br/>dept = 1"] --> R1["Engineering report"] B["copy 2<br/>dept = 2"] --> R2["Marketing report"] C["copy 3<br/>one literal missed"] --> R3["mixed report<br/>drift bug"] style R3 fill:#b45309,color:#fff style R1 fill:#0e7490,color:#fff style R2 fill:#0e7490,color:#fff
Factor the report body into a CTE
A common table expression, written WITH name AS (...), is a named, temporary result that exists for the duration of one query. You define it once at the top and then use it as if it were a table. Its value for reuse is that the report's calculation, the part you want to keep identical across runs, moves into the CTE, while the part that changes between runs, the filter on the input, sits below it.
Think of the CTE as the report's engine and the final SELECT as its steering wheel. The engine computes headcount and payroll for every department the same way every time; the steering wheel decides which department, or all of them, reaches the reader. Build the engine once, then steer it many ways.
SELECT d.name AS department, COUNT(*) AS headcount FROM employees e JOIN departments d ON e.department_id = d.id WHERE e.department_id = 1 GROUP BY d.id, d.name;
The params CTE: the input in one place
The trick that mimics a parameter in pure SQL is a tiny CTE whose only job is to hold the input value. WITH params AS (SELECT 1 AS p_dept) creates a one-row, one-column result you can read anywhere below with a scalar subquery, (SELECT p_dept FROM params). Now the literal 1 appears exactly once, at the very top, and the report body refers to it by name. To re-run for Marketing you change that single 1 to a 2 and touch nothing else.
In a real application the value would arrive as a bound parameter from your code, never pasted into the SQL string, which is also how you avoid SQL injection. The browser player here cannot bind parameters, so the params CTE stands in for that single source of truth and lets us practise the shape that real reports take.
WITH params AS ( SELECT 1 AS p_dept ) SELECT d.name AS department, COUNT(*) AS headcount FROM employees e JOIN departments d ON e.department_id = d.id WHERE e.department_id = (SELECT p_dept FROM params) GROUP BY d.id, d.name;
flowchart TD P["params CTE<br/>SELECT 1 AS p_dept"] --> W["WHERE reads the param<br/>= ( SELECT p_dept FROM params )"] W --> R["report body<br/>never names the literal"] R --> O["change the 1, re-run for any dept"] style P fill:#0e7490,color:#fff style O fill:#1e293b,color:#fff
Reuse the same body for many questions
Once the body is a CTE, the same calculation answers several questions with almost no rewrite. Define the per-department summary once, then point different final SELECTs at it: one returns a single department by reading the params CTE, another drops the filter and returns every department for an overview, a third adds an ORDER BY to surface the biggest. The body, the part most likely to be wrong, is written and tested exactly once.
This is the real payoff of parameterizing. The cost of a buggy headcount formula is no longer multiplied by the number of reports that use it, because there is only one formula. Every consumer reads the same CTE, so a fix lands everywhere at once.
WITH per_department AS (
SELECT d.id AS dept_id, d.name AS department,
COUNT(*) AS headcount, SUM(e.salary) AS payroll
FROM employees e
JOIN departments d ON e.department_id = d.id
GROUP BY d.id, d.name
),
params AS (
SELECT 3 AS p_dept
)
SELECT department, headcount, payroll
FROM per_department
WHERE dept_id = (SELECT p_dept FROM params);
Parameterize by anything you swap often
The input does not have to be a department. Any value you find yourself changing between runs is a candidate for the params CTE: a salary threshold for a high-earner report, a status such as 'active' for a project pipeline, a hire year for a cohort. The technique is identical in every case, name the value once at the top and read it by name below, and the report becomes a template you steer with one edit instead of a script you rewrite.
A useful discipline is to scan a finished report for every literal number and quoted string sitting in its WHERE clause. Each one is a place the report is hard-coded, and each is a candidate to lift into the params CTE. You will not parameterize all of them, a fixed condition may be part of the question itself, but the values a reader would reasonably change belong at the top, where changing them is a one-line edit instead of a careful search.
Use a params CTE set to 2 to list the name and salary of every employee in that department. Read the param with (SELECT p_dept FROM params) in the WHERE.
WITH params AS ( SELECT 2 AS p_dept ) SELECT e.name, e.salary FROM employees e -- filter by the department stored in params ;
The filter is WHERE e.department_id = (SELECT p_dept FROM params).
Department 2 is Marketing, so Eli and Farah should come back.
WITH params AS (SELECT 2 AS p_dept) SELECT e.name, e.salary FROM employees e WHERE e.department_id = (SELECT p_dept FROM params);
Build a parameterized department summary: a params CTE set to 4, then each department's name as department, its headcount, and its payroll (SUM of salary). Filter the final result by the param so only the chosen department is returned.
WITH params AS ( SELECT 4 AS p_dept ) SELECT d.name AS department, -- headcount and payroll here FROM employees e JOIN departments d ON e.department_id = d.id -- filter by the param and group here ;
Headcount is COUNT(*); payroll is SUM(e.salary).
Filter with WHERE e.department_id = (SELECT p_dept FROM params), then GROUP BY d.id, d.name.
Department 4 is Support, with three employees.
WITH params AS (SELECT 4 AS p_dept) SELECT d.name AS department, COUNT(*) AS headcount, SUM(e.salary) AS payroll FROM employees e JOIN departments d ON e.department_id = d.id WHERE e.department_id = (SELECT p_dept FROM params) GROUP BY d.id, d.name;
This report was copied and the department id was typed in two places that drifted apart: the headcount filter says 1 but the budget filter says 2, so it shows Engineering people beside Marketing's budget. Rebuild it with a params CTE set to 1, reading (SELECT p_dept FROM params) in all three places, so the value is single-sourced and the report is consistent.
SELECT d.name AS department,
(SELECT COUNT(*) FROM employees WHERE department_id = 1) AS headcount,
(SELECT SUM(budget) FROM projects WHERE department_id = 2) AS budget
FROM departments d
WHERE d.id = 1
;
Declare WITH params AS (SELECT 1 AS p_dept) at the top.
Replace each literal 1 and 2 with (SELECT p_dept FROM params), including the one in WHERE d.id = ... .
Done right, Engineering shows headcount 4 and budget 4500000, consistent on every column.
WITH params AS (SELECT 1 AS p_dept) SELECT d.name AS department, (SELECT COUNT(*) FROM employees WHERE department_id = (SELECT p_dept FROM params)) AS headcount, (SELECT SUM(budget) FROM projects WHERE department_id = (SELECT p_dept FROM params)) AS budget FROM departments d WHERE d.id = (SELECT p_dept FROM params);
Transfer skill: build a reusable report with an optional filter. A per_department CTE holds each department and its headcount; a params CTE holds the input. The final WHERE must return one department when p_dept is a concrete id, and every department when p_dept is NULL. Submit the all-departments version, with p_dept set to NULL.
WITH per_department AS ( SELECT d.id AS dept_id, d.name AS department, COUNT(*) AS headcount FROM employees e JOIN departments d ON e.department_id = d.id GROUP BY d.id, d.name ), params AS ( SELECT NULL AS p_dept ) SELECT department, headcount FROM per_department -- write a WHERE that returns one dept when p_dept is set, all when it is NULL ;
The optional filter is WHERE dept_id = (SELECT p_dept FROM params) OR (SELECT p_dept FROM params) IS NULL.
With p_dept NULL, the OR is always true, so every department passes.
You should get all five departments back.
WITH per_department AS (SELECT d.id AS dept_id, d.name AS department, COUNT(*) AS headcount FROM employees e JOIN departments d ON e.department_id = d.id GROUP BY d.id, d.name), params AS (SELECT NULL AS p_dept) SELECT department, headcount FROM per_department WHERE dept_id = (SELECT p_dept FROM params) OR (SELECT p_dept FROM params) IS NULL;
Why factor the report body into a CTE and the input into a params CTE?
A single source for the input means copies can never disagree. The body is written and tested once, and changing the params value re-runs the same report for any input without editing the body.
Recap
- Parameterizing a report means its input value appears in one place and the body never spells it out, so copies cannot drift apart.
- Factor the reusable calculation into a CTE (
WITH name AS (...)) and write it, and test it, exactly once. - A params CTE holding
SELECT 1 AS p_deptmimics a bound parameter in pure SQL; read it with(SELECT p_dept FROM params). - Make a filter optional with
OR (SELECT p_dept FROM params) IS NULLto serve one department or all of them from one query. - In a real application the value arrives as a bound parameter, never interpolated into the SQL, which also blocks SQL injection.
Next you will prepare these reports for the last mile: formatting and exporting the result set so a spreadsheet or reporting tool receives clean, stable columns.