Sharon Ben-Moshe is the founder of sqlfmt, a browser-based SQL formatter and validator with misspelled-keyword suggestions across PostgreSQL, MySQL, SQLite, SQL Server, and BigQuery.
Formatting SQL stored procedures means aligning parameters, indenting BEGIN/END blocks, and handling PL/pgSQL, T-SQL, and MySQL control flow correctly.
Quick answer
Format SQL stored procedures by handling three layers on their own terms: align the parameter list one entry per line, indent every BEGIN/END and IF/LOOP block one level deeper than the keyword that opens it, and keep variable declarations grouped in one place. General-purpose SQL formatters will format the plain SQL statements inside a procedure, but they typically leave PL/pgSQL's dollar-quoted body untouched and won't reindent a BEGIN/END wrapper, so most procedural code still needs a manual formatting pass on top.
Can a SQL formatter format the inside of a CREATE PROCEDURE statement?
It depends on the dialect. PostgreSQL wraps PL/pgSQL bodies in dollar-quoting, so most formatters treat the whole body as one opaque string and leave it untouched. SQL Server and MySQL procedure bodies are closer to plain SQL statements inside BEGIN/END, so a formatter will usually indent the individual statements, though not the BEGIN/END wrapper itself.
Should stored procedure keywords be uppercase or lowercase?
Whatever your team already uses for query keywords — consistency matters more than the specific choice. If SELECT and FROM are uppercase in your queries, keep BEGIN, END, IF, LOOP, and DECLARE uppercase too instead of treating procedural keywords as a separate decision.
Why does formatting turn 'DELIMITER ;' into 'DELIMITER;'?
General-purpose formatters don't recognize DELIMITER as a keyword, so they normalize the whitespace around it like any other token and collapse the required space. Because DELIMITER is a mysql command-line client instruction rather than real SQL, that missing space can make the client misread the next line — always check it after formatting.
How many parameters justify putting each on its own line?
Two or three inline parameters are usually still readable on one line. Past that, one parameter per line — with its type and default value — keeps the signature scannable and limits future diffs to a single line when someone adds or renames a parameter.
Do PL/pgSQL, T-SQL, and MySQL handle errors inside a procedure the same way?
No. PL/pgSQL uses an EXCEPTION WHEN condition THEN block inside the same BEGIN/END; T-SQL uses a separate BEGIN TRY / BEGIN CATCH construct; MySQL uses DECLARE ... HANDLER statements declared before the body. Format each one the way you'd format any nested block — nothing about error handling changes the indentation rules.
DELETE removes selected rows, TRUNCATE empties a table, and DROP removes the table itself. Learn the crucial differences before running destructive SQL.
Format SQL stored procedures by treating three layers separately: the CREATE PROCEDURE (or CREATE FUNCTION) header and parameter list, the BEGIN/END block that holds the body, and the dialect-specific control-flow syntax inside it — IF/THEN, LOOP, WHILE, TRY/CATCH. Query formatting advice covers SELECT statements in exhaustive detail; almost none of it survives contact with a 200-line procedure.
The reason is structural, not stylistic. PostgreSQL wraps a PL/pgSQL body in dollar-quoting ($$ ... $$), so the outer parser treats the whole thing as one opaque string. MySQL needs a client-side DELIMITER change before BEGIN/END will even parse. SQL Server's BEGIN/END is closer to plain T-SQL, but it still isn't part of the SELECT grammar most formatters were built around. A tool that formats queries beautifully can leave a procedure body completely untouched, or subtly break it.
This post covers what to fix by hand and how, dialect by dialect — parameter list alignment, BEGIN/END indentation, DECLARE sections, and IF/LOOP/WHILE blocks — with real examples in PostgreSQL's PL/pgSQL, SQL Server's T-SQL, and MySQL. If you haven't read our general SQL formatting best practices, start there for the query-level rules; this post picks up where that one stops, plus a checklist you can run before committing a procedure.
Why do general SQL formatters struggle with stored procedure bodies?
Run a PL/pgSQL procedure through a generic SQL formatter and the header gets tidied up, but everything between the $$ markers comes back exactly as it went in. That's not a bug — PostgreSQL's own parser treats a dollar-quoted string as a single token, so a formatter respecting that grammar has no statements to reformat inside it, only one long string it correctly leaves alone.
MySQL has a related but different problem. A stored procedure body needs DELIMITER $$ before it and DELIMITER ; after it, so the mysql client doesn't split the procedure on every internal semicolon. DELIMITER is a client command, not SQL, so a formatter that normalizes whitespace around unrecognized tokens can quietly turn "DELIMITER ;" into "DELIMITER;" — breaking the very command it's supposed to restore.
T-SQL avoids both problems. Everything inside a stored procedure's BEGIN/END is ordinary T-SQL, so a formatter can indent the SELECT, INSERT, and UPDATE statements inside it just fine. What it typically won't do is indent the BEGIN/END wrapper itself relative to the CREATE PROCEDURE header, or apply procedure-specific conventions like keeping SET NOCOUNT ON on its own line.
How do you format a CREATE PROCEDURE or CREATE FUNCTION parameter list?
Two or three inline parameters are still readable on one line. Past that, put each parameter on its own line — name, type, and default value together — so the signature is scannable without horizontal scrolling, and adding a parameter later only touches one line in a diff.
Before: parameter list crammed onto one line
CREATE OR REPLACE FUNCTION calculate_shipping(order_id int, weight_kg numeric, destination_country text, expedited boolean DEFAULT false, carrier text DEFAULT 'standard') RETURNS numeric LANGUAGE plpgsql AS $$ BEGIN RETURN weight_kg * CASE WHEN expedited THEN 2.5 ELSE 1.0 END; END; $$;
After: one parameter per line
CREATE OR REPLACE FUNCTION calculate_shipping( order_id int, weight_kg numeric, destination_country text, expedited boolean DEFAULT false, carrier text DEFAULT 'standard')RETURNS numericLANGUAGE plpgsqlAS $$BEGIN RETURN weight_kg * CASE WHEN expedited THEN 2.5 ELSE 1.0 END;END;$$;
Resist the urge to vertically align every type into its own column. It looks tidy the day you write it, but the moment a parameter name changes length, every line below it either gets a noisy realignment diff or the columns drift out of sync. One parameter per line, consistently indented, is enough — you don't need a second axis of alignment.
How should you indent a PL/pgSQL procedure or function body?
A PL/pgSQL block has a fixed shape: an optional DECLARE section, BEGIN, the statement body, an optional EXCEPTION section, and END — see PostgreSQL's PL/pgSQL structure documentation. Format each section as its own visual block: DECLARE gets one variable per line, the statements after BEGIN are indented one level in, and each IF/END IF or LOOP/END LOOP pair adds one more level on top of that.
Before: unformatted PL/pgSQL procedure
CREATE OR REPLACE PROCEDURE raise_salary(emp_id int, increase numeric) LANGUAGE plpgsql AS $$ declare current_sal numeric; begin select salary into current_sal from employees where id = emp_id; if current_sal is null then raise exception 'no such employee %', emp_id; end if; update employees set salary = salary + increase where id = emp_id; end; $$;
After: formatted PL/pgSQL procedure
CREATE OR REPLACE PROCEDURE raise_salary( emp_id int, increase numeric)LANGUAGE plpgsqlAS $$DECLARE current_sal numeric;BEGIN SELECT salary INTO current_sal FROM employees WHERE id = emp_id; IF current_sal IS NULL THEN RAISE EXCEPTION 'no such employee %', emp_id; END IF; UPDATE employees SET salary = salary + increase WHERE id = emp_id;END;$$;
General SQL formatters won't produce that output for you — the whole $$ ... $$ block is opaque to them, as discussed above — so this pass is manual. The good news is that PL/pgSQL's control structures (IF/ELSIF/ELSE, LOOP, WHILE, FOR) all follow the same rule: the opening keyword and its condition share a line, the body is indented one level, and the matching END keyword lines up with the opening keyword's indentation, not the condition's.
A batched-delete procedure shows the LOOP/EXIT WHEN pattern, and why EXCEPTION sections and GET DIAGNOSTICS get their own visual space:
PL/pgSQL LOOP with EXIT WHEN, indented one level per block
CREATE OR REPLACE PROCEDURE archive_old_orders(cutoff_date date)LANGUAGE plpgsqlAS $$DECLARE rows_affected int;BEGIN LOOP DELETE FROM orders WHERE order_date < cutoff_date AND ctid IN ( SELECT ctid FROM orders WHERE order_date < cutoff_date LIMIT 1000 ); GET DIAGNOSTICS rows_affected = ROW_COUNT; EXIT WHEN rows_affected = 0; END LOOP;END;$$;
How should you format a T-SQL stored procedure?
A T-SQL procedure's CREATE PROCEDURE header takes the parameter list, then AS BEGIN opens the body. Convention (not syntax) says: name user procedures with a team-chosen prefix other than sp_, since SQL Server searches for sp_-prefixed names in a special way; put SET NOCOUNT ON as the first statement in the body on its own line; and indent everything inside BEGIN/END one level, with END lined back up under CREATE PROCEDURE.
Before: unformatted T-SQL stored procedure
create procedure dbo.usp_get_active_customers @minOrders int = 5 as begin set nocount on; select c.customer_id,c.name,count(o.order_id) as order_count from dbo.customers c join dbo.orders o on o.customer_id=c.customer_id where c.active=1 group by c.customer_id,c.name having count(o.order_id)>=@minOrders order by order_count desc; end;
After: formatted T-SQL stored procedure
CREATE PROCEDURE dbo.usp_get_active_customers @minOrders INT = 5ASBEGIN SET NOCOUNT ON; SELECT c.customer_id, c.name, COUNT(o.order_id) AS order_count FROM dbo.customers AS c JOIN dbo.orders AS o ON o.customer_id = c.customer_id WHERE c.active = 1 GROUP BY c.customer_id, c.name HAVING COUNT(o.order_id) >= @minOrders ORDER BY order_count DESC;END;
A generic formatter will happily indent the SELECT statement inside that body — it's plain T-SQL — but it won't reindent BEGIN/END to sit under CREATE PROCEDURE, and it can awkwardly split single-line statements like SET NOCOUNT ON onto their own tokens. Both are quick manual fixes once you know to look for them.
How does MySQL's DELIMITER command change procedure formatting?
MySQL's stored program syntax requires switching the statement delimiter before CREATE PROCEDURE, so the mysql client treats the whole BEGIN/END block as one statement instead of stopping at the first internal semicolon. A correctly formatted procedure looks like this:
Correctly delimited MySQL procedure
DELIMITER $$CREATE PROCEDURE get_top_customers(IN min_orders INT)BEGIN SELECT c.customer_id, c.name, COUNT(o.order_id) AS order_count FROM customers c JOIN orders o ON o.customer_id = c.customer_id GROUP BY c.customer_id, c.name HAVING COUNT(o.order_id) >= min_orders ORDER BY order_count DESC;END $$DELIMITER ;
Run that through a generic formatter and the SELECT inside BEGIN/END gets indented correctly, but watch the last line: DELIMITER ; frequently comes back as DELIMITER; with the required space stripped, because the formatter treats DELIMITER as an unrecognized identifier and normalizes whitespace around it like any other token.
After: generic formatter output — note DELIMITER; lost its space
DELIMITER $$CREATE PROCEDURE get_top_customers (IN min_orders INT) BEGINSELECT c.customer_id, c.name, COUNT(o.order_id) AS order_countFROM customers c JOIN orders o ON o.customer_id = c.customer_idGROUP BY c.customer_id, c.nameHAVING COUNT(o.order_id) >= min_ordersORDER BY order_count DESC;END $$ DELIMITER;
Put the space back manually before you run this in the mysql client — that missing space is a common way to trigger "You Have an Error in Your SQL Syntax" on the very next statement, since the client no longer recognizes DELIMITER as its own command.
What's different across PL/pgSQL, T-SQL, and MySQL procedural syntax?
All three agree on the big picture — a header, a parameter list, a body — and disagree on almost everything inside it:
Block delimiters: PL/pgSQL wraps the whole body in dollar-quoting ($$ ... $$); T-SQL and MySQL use BEGIN/END directly, but MySQL additionally needs a client-side DELIMITER change to get there.
Variable declarations: PL/pgSQL groups them in one DECLARE section before BEGIN; T-SQL declares each variable inline with DECLARE @name TYPE, usually right after AS BEGIN; MySQL declares variables inline too, but they must come before any other statement in the block.
Error handling: PL/pgSQL uses an EXCEPTION WHEN condition THEN block inside the same BEGIN/END; T-SQL uses a separate BEGIN TRY / BEGIN CATCH construct; MySQL uses DECLARE ... HANDLER, declared up front alongside variables.
Parameter modes: all three support IN, OUT, and INOUT parameters, but the syntax differs — PL/pgSQL and MySQL write the mode before the name (IN emp_id int), while T-SQL marks a parameter OUTPUT after its type (@result INT OUTPUT).
If you're porting stored logic between engines rather than just formatting it, our guide to MySQL vs PostgreSQL syntax differences covers the query-level gotchas that compound with these procedural ones — auto-increment, quoting, and upserts all show up inside procedure bodies too.
Checklist: before you commit a stored procedure
Run through this before you commit CREATE PROCEDURE or CREATE FUNCTION code, in any dialect:
Format the header and parameter list first — one parameter per line past two or three, with the type and default value on the same line.
Group every variable declaration in one place (the DECLARE section, or the top of the block) instead of scattering DECLARE statements through the body.
Indent every BEGIN/END, IF/END IF, and LOOP/END LOOP pair one consistent level deeper than the keyword that opens it, regardless of dialect.
Keep THEN, LOOP, and BEGIN on the same line as the condition or keyword that opens them, and align the matching END with that opening keyword's indentation.
Run the plain SQL statements inside the body through a formatter first, then hand-fix the procedural wrapper — DECLARE sections, dollar-quoting, and BEGIN/END indentation usually need a manual pass regardless of dialect.
Restore any whitespace a formatter stripped from client-only commands, like MySQL's DELIMITER, before you run the script again.
Pick one keyword case for procedural keywords too — if SELECT and FROM are uppercase, BEGIN, END, IF, and LOOP should be as well.
Separate DECLARE, BEGIN, and EXCEPTION (or TRY/CATCH) sections with a blank line so the block's structure is visible at a glance, not just from its indentation.
Test the reformatted procedure against a real connection before committing it — a semicolon dropped inside a dollar-quoted body won't show up in a syntax highlighter.
Wrapping up
Stored procedure formatting is three separate problems wearing one CREATE PROCEDURE trench coat: the header and parameter list, the BEGIN/END block structure, and dialect-specific control flow. A generic formatter usually solves the first cleanly, sometimes solves the third for T-SQL and MySQL, and almost never touches a PL/pgSQL body at all — the conventions above are what fill that gap.
sqlfmt formats the plain SQL statements inside a procedure or function body across PostgreSQL, MySQL, SQLite, SQL Server, and BigQuery, and flags misspelled keywords before they turn into a parser error. Run the query portions of your next procedure through it, then apply the manual conventions above to the wrapper; the Pro plan adds live formatting as you type if you're doing this daily.