PostgreSQL Syntax Checker
This postgres syntax checker parses SQL with the PostgreSQL parser itself, compiled to WebAssembly and running in your browser, so it needs neither a database nor a connection string. A rejected statement comes back carrying the token the grammar stopped on, with its line and column.
Press Check. The parser's answer appears here, with the line and column when it stops.
Nothing is uploaded. The parser runs inside this tab, and Ctrl+Enter, Cmd+Enter on a Mac, runs a check.
What it does and how to use it
Paste one statement or a whole migration file and press Check, or run it from the keyboard with Ctrl+Enter (Cmd+Enter on a Mac). Either the grammar accepts it and the tool counts the statements, or it stops and hands back the parser's own message, the position, and the offending line with a caret under the token.
The parser is libpg-query, the PostgreSQL C parser compiled to WebAssembly. About a megabyte arrives on your first Check rather than on page load, so that run is the slow one.
It is one grammar, PostgreSQL 17.7, and the number 170007 printed under every result is that version the way the server writes it. A clause that arrived in PostgreSQL 18, such as virtual on a generated column, is rejected here with syntax error at or near "virtual" even though an 18 server takes it, and there is no picker to change that. As a postgresql syntax validator online it covers the 17 grammar and nothing newer; the surest way to check postgres query syntax for another release is still that release's own server.
Why you see "syntax error at or near"
That message names the first token the grammar could not place, which is often one past the real mistake, so the fix usually sits to the left of the caret. The headings below are the exact strings the parser prints, and every example was run through the parser this page ships, libpg-query 17.7.4, with the message copied from what it returned.
syntax error at or near "user"
Reserved words in a name position are the commonest cause. create table user (id int); stops at user, column 14, and so does insert into user (id) values (1);. The word is reserved because on its own it is a function returning the current role, which is also why select * from user; parses without complaint. The same fate meets order, so select id, order from line_items; stops at order. Double quotes turn the word back into a name, create table "user" (id int);, and every later reference has to carry the quotes too.
syntax error at end of input
Here the grammar ran out of text before it ran out of expectations. select * from posts where id in (1, 2 is missing its closing parenthesis and select * from posts where id = 1 and its right-hand operand, and both report a position one past the last character. A bare insert into posts (id) values does the same. The caret has nowhere to point but the end, so the cure is to look at what the last token promised and supply it.
unterminated quoted string at or near
An opening quote with no partner carries the scanner to the end of the input. select * from posts where title = 'Hello; gives unterminated quoted string at or near "'Hello;", and the quoted fragment runs from that first quote to the last character because the close was never found. The sneakier version is an apostrophe inside the string. insert into posts (title) values ('it's here'); closes the string after it, so the message is syntax error at or near "s" instead, with the leftover quote dangling behind it. Doubling the apostrophe, 'it''s here', is the fix, and a dollar-quoted $$it's here$$ sidesteps the problem entirely; the escape string tool writes both forms. A missing closing double quote reports unterminated quoted identifier in the same shape.
syntax error at or near "-"
Outside quotes a hyphen is the subtraction operator. select * from my-table; stops at the hyphen, column 17, because the grammar has read my - table and is now looking at an expression where a table name should be. Quoting the identifier, "my-table", keeps the hyphen inside the name. The same happens to a column called created-at and to a schema copied from a system that allowed dashes, and the durable fix is an underscore.
syntax error at or near "union"
select 1 order by 1 union select 2; stops at union. Without parentheses an ORDER BY applies to the result of the whole set operation, so once the grammar has read one it treats the statement as complete and cannot accept another arm. Wrap each arm that needs its own ordering, (select 1 order by 1) union (select 2);, or move a single ORDER BY to the end where it sorts the union's output.
syntax error at or near "if"
if true then select 1; end if; fails at column 1. IF is a PL/pgSQL statement, and the SQL grammar this checker runs never sees PL/pgSQL, so a conditional written at the top level has no meaning to it. Wrapping the block, do $$ begin if true then perform 1; end if; end $$;, parses. A related message hides behind a typo. create table if exists posts (id int); stops at exists, because the grammar knows if not exists and nothing shorter.
syntax error at or near "select"
A select the grammar was not expecting is nearly always a missing semicolon. select id from posts select title from posts; stops at the second select, column 22, since the first statement was still open. insert into posts (id) values select id from staging; stops at select because values and a query are alternatives; drop values and write insert into posts (id) select id from staging;. And select select from posts; fails at column 8, since select cannot be a column name without quotes.
syntax error at or near "create"
create table a (id int) create table b (id int); stops at the second create, column 25, and the missing semicolon after the first closing parenthesis is the whole story. When the caret lands on table instead, as in create or replace table posts (id int);, the clause itself is the problem, since PostgreSQL has create or replace for functions and views and not for tables. Drop or replace, or use create table if not exists. create table posts (create int); stops at column 21 for the reserved-word reason above.
syntax error at or near "insert"
Two insert statements run together, insert into posts (id) values (1) insert into posts (id) values (2);, stop at the second insert, column 35, and the missing semicolon is again the cause. A different shape is insert posts (id) values (1);, which stops at posts rather than at insert, because into is mandatory and the grammar had nowhere to put a bare name.
syntax error at or near "returning"
select id from posts returning id; stops at returning, column 22. The clause exists on insert, update, delete and merge, and a select already returns rows, so there is nothing to attach it to. Order matters as well. delete from posts returning id where id = 1; stops at where, since returning has to be the last clause, and delete from posts where id = 1 returning id; parses.
syntax error at or near "$1"
A $1 parameter stands in for a value and never for a name. select * from posts limit $1; and insert into posts (id) values ($1); both parse, while select * from $1; stops at the placeholder, column 15, because a table name cannot arrive through a parameter. Build the identifier into the query text instead, and if the name comes from outside, run it through quote_ident or format('%I', name) on the way in.
syntax error at or near "merge"
The 17 grammar accepts merge, so on this page the message comes from a merge that starts before the previous statement ended. insert into posts (id) values (1) merge into posts p using staging s on p.id = s.id when matched then delete; stops at merge, column 35, over a missing semicolon. An action the grammar does not offer produces it too, and merge into posts p using staging s on p.id = s.id when matched then merge; stops at the final merge, since a matched branch may only update, delete or do nothing. If your server prints this message for a merge that stands alone and parses cleanly here, the server is older than PostgreSQL 15, where the command first appeared. A merge missing every when clause fails at the semicolon instead.
A message that is not in this list still follows the same rule. Find the token in quotes, then read leftwards for the clause the grammar was in the middle of.
Frequently asked questions
It is libpg-query, the parser from the PostgreSQL sources compiled to WebAssembly, so the message it prints is the same one a PostgreSQL 17 server prints for the same input.
One grammar, PostgreSQL 17.7, and every result prints the version number 170007 under the verdict. Syntax that arrived in 18 is rejected here, and in the other direction a 14 server rejects MERGE while this page accepts it, so whenever the two disagree the server is the one to believe.
The checker counts columns from 1 and counts a tab as one character, where many editors show a tab as four or eight columns. On a multi-line input it also prints the absolute character number, which is what a server error calls Position, counted in characters rather than bytes from the start of the whole text, so accented characters before the token do not shift it.
It cannot, because it holds no catalog. A misspelled column or a table that was never created passes here and fails on the server with 42703 or 42P01 rather than the 42601 a syntax error carries. The grammar reads the shape of a statement and nothing about the database behind it.
A whole file is fine. Paste it and either the count of statements comes back or the first statement that fails, with its line number, since the parser stops at the first error rather than collecting them all. One blind spot is the body of a PL/pgSQL function, which the SQL grammar carries as a string, so a typo between $$ markers passes here and fails when the function is created. A body written as begin atomic is real SQL and is checked.
The parser is WebAssembly running inside this tab. Nothing you paste is sent anywhere, and downloading the parser is the only request this page makes.
A regex validator matches whatever patterns its author thought of. Running the grammar instead means dollar-quoted blocks, ON CONFLICT, window functions and MERGE come back clean.
Other free PostgreSQL tools
- FormatterIndents a Postgres query and puts the keywords where you expect them, so a long CTE stops being one unreadable line. Minifies it again when you need it back on one line.
- String escaperQuotes a value for Postgres the way quote_literal does, and also as an E-string, dollar quoted, as a LIKE pattern and as an identifier. Paste any of those back and it returns the plain text.
Further reading: Writing a Postgres seed script that survives the next migration.