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.
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.
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:
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.
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:
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.
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.