Mirajv1.0
EN

35. PostgreSQL: SELECT queries

This chapter describes the SELECT statement on a MIRAJ instance in the PostgreSQL language: select list, FROM and joins, WHERE, GROUP BY / HAVING, ORDER BY, LIMIT / OFFSET / FETCH FIRST, subqueries, set operations, common table expressions (WITH), window functions, read locks and EXPLAIN. It describes the PostgreSQL language as MIRAJ speaks it: every example was run on a real instance and the results were copied; whatever differs from PostgreSQL is pointed out in the text and collected in 35.15.

The examples use the ventes database (schema), populated once for the whole chapter:

CREATE SCHEMA ventes;
SET search_path TO ventes;

CREATE TABLE client (
    id      serial PRIMARY KEY,
    nom     varchar(50) NOT NULL,
    ville   varchar(50),
    email   varchar(80) UNIQUE,
    solde   numeric(10,2) NOT NULL DEFAULT 0,
    cree_le timestamp NOT NULL DEFAULT now()
);

CREATE TABLE article (
    id      serial PRIMARY KEY,
    code    varchar(20) NOT NULL UNIQUE,
    libelle text NOT NULL,
    prix    numeric(10,2) NOT NULL CHECK (prix >= 0),
    stock   integer NOT NULL DEFAULT 0,
    actif   boolean NOT NULL DEFAULT true
);

CREATE TABLE commande (
    id        serial PRIMARY KEY,
    client_id integer NOT NULL REFERENCES client (id),
    date_cmd  date NOT NULL DEFAULT current_date,
    etat      varchar(12) NOT NULL DEFAULT 'ouverte'
);

CREATE TABLE ligne_commande (
    commande_id integer NOT NULL REFERENCES commande (id) ON DELETE CASCADE,
    article_id  integer NOT NULL REFERENCES article (id),
    quantite    integer NOT NULL CHECK (quantite > 0),
    prix_unit   numeric(10,2) NOT NULL,
    total       numeric(12,2) GENERATED ALWAYS AS (quantite * prix_unit) STORED,
    PRIMARY KEY (commande_id, article_id)
);

INSERT INTO client (nom, ville, email, solde) VALUES
    ('Alice', 'Oran', '[email protected]', 100.50),
    ('Bob', 'Alger', '[email protected]', -20.00),
    ('Chloé', 'Oran', NULL, 0.00),
    ('David', 'Constantine', '[email protected]', 250.00),
    ('Émile', 'Alger', NULL, 75.25),
    ('Fatima', NULL, '[email protected]', 10.00);

INSERT INTO article (code, libelle, prix, stock, actif) VALUES
    ('ST-01', 'Stylo bille', 1.20, 500, true),
    ('CA-01', 'Cahier 96 pages', 3.50, 200, true),
    ('CL-01', 'Classeur', 6.90, 80, true),
    ('AG-01', 'Agrafeuse', 14.00, 0, false),
    ('GO-01', 'Gomme', 0.80, 150, true),
    ('TR-01', 'Trombones', 2.10, 40, true);

INSERT INTO commande (client_id, date_cmd, etat) VALUES
    (1, '2026-09-01', 'livrée'),
    (1, '2026-09-15', 'ouverte'),
    (2, '2026-09-20', 'livrée'),
    (3, '2026-10-02', 'ouverte'),
    (5, '2026-10-05', 'annulée');

INSERT INTO ligne_commande (commande_id, article_id, quantite, prix_unit) VALUES
    (1, 1, 10, 1.20), (1, 2, 2, 3.50),
    (2, 3, 1, 6.90),
    (3, 1, 100, 1.10), (3, 5, 20, 0.80),
    (4, 2, 5, 3.50), (4, 4, 1, 14.00);

Clients 4 (David) and 6 (Fatima) have no orders, order 5 has no line, and article TR-01 has never been ordered: these cases are used for the outer joins.

Results are shown the way psql shows them (NULL is spelled out for readability). The functions and operators mentioned are described in chapter 36. SQL functions, the types in chapter 32. Data types.


35.1 Synopsis#

[WITH [RECURSIVE] name [(column, ...)] AS [[NOT] MATERIALIZED] (query) [, ...]]
SELECT [ALL | DISTINCT] item [[AS] alias] [, ...]
    [FROM source [, ...]]
    [WHERE condition]
    [GROUP BY {expr | position | ROLLUP (...) | CUBE (...) | GROUPING SETS (...)} [, ...]]
    [HAVING condition]
    [WINDOW name AS (definition) [, ...]]
    [{UNION | INTERSECT | EXCEPT} [ALL | DISTINCT] query]
    [ORDER BY expr [ASC | DESC] [NULLS {FIRST | LAST}] [, ...]]
    [LIMIT {n | ALL}] [OFFSET m [ROW | ROWS]]
    [FETCH {FIRST | NEXT} [n] {ROW | ROWS} {ONLY | WITH TIES}]
    [FOR {UPDATE | NO KEY UPDATE | SHARE | KEY SHARE} [OF table [, ...]] [NOWAIT | SKIP LOCKED]]

where source is:

    [ONLY] [schema.]table [*] [[AS] alias [(column, ...)]]
  | (query) [AS] alias [(column, ...)]
  | (VALUES (...), ...) [AS] alias [(column, ...)]
  | [LATERAL] function(arguments) [WITH ORDINALITY] [[AS] alias [(column, ...)]]
  | LATERAL (query) [AS] alias [(column, ...)]
  | source [INNER | LEFT [OUTER] | RIGHT [OUTER] | FULL [OUTER]] JOIN source {ON condition | USING (column, ...)}
  | source NATURAL [INNER | LEFT | RIGHT | FULL] JOIN source
  | source CROSS JOIN source

A query returns its result followed by the tag SELECT n, where n is the number of rows.


35.2 Select list#

Each item is an expression, with an optional alias (AS alias, or the alias alone); * stands for all columns, table.* for those of one table.

SELECT nom, solde AS balance, solde * 1.1 marge FROM client WHERE id <= 2 ORDER BY id;
 nom   | balance | marge
-------+---------+---------
 Alice | 100.50  | 110.550
 Bob   | -20.00  | -22.000
SELECT * FROM article WHERE id = 1;
SELECT c.*, 1 AS actif FROM client c WHERE c.id = 1;      -- all columns of c, plus one
SELECT 1 AS a, 'x' AS b, NULL AS c, true AS d;            -- no FROM: one row

Identifiers and aliases#

  • An identifier is written bare or in double quotes ("Nom du client"); quotes allow spaces, symbols and reserved words. An alias after AS can be a reserved word (AS select).
  • Names are case-insensitive, even when quoted: "Client", client and CLIENT designate the same table, "ID" the column id (in PostgreSQL, a quoted name is case-sensitive). The alias as written is returned to the client unchanged.
  • A column is qualified by its table or the table's alias (c.nom), a table by its schema (ventes.client); public.client designates the table of the current database.
SELECT nom AS "Nom du client", solde AS "Solde (DA)" FROM client WHERE id = 1;
 Nom du client | Solde (DA)
---------------+------------
 Alice         | 100.50

Name of a computed column#

An expression without an alias is named after the text written in the query, where PostgreSQL names it ?column? or after the function name:

SELECT 1 + 1, 'a' || 'b', upper('x'), solde * 2, solde::int FROM client WHERE id = 1;
 1 + 1 | 'a' || 'b' | upper('x') | solde * 2 | solde::int
-------+------------+------------+-----------+------------
 2     | ab         | X          | 201.00    | 101

Likewise count(*) is named count(*) (PostgreSQL: count) and sum(solde) is named sum(solde). Give an alias to every computed column that the application reads by name: the code then works on both servers.

Conditions in the list#

A condition placed in the list is a boolean, returned as t / f (or NULL):

SELECT nom, solde > 0 AS crediteur, ville IS NULL AS sans_ville, ville = 'Oran' AS oran
FROM client ORDER BY id;
 nom    | crediteur | sans_ville | oran
--------+-----------+------------+------
 Alice  | t         | f          | t
 Bob    | f         | f          | f
 Chloé  | f         | f          | t
 David  | t         | f          | f
 Émile  | t         | f          | f
 Fatima | t         | t          | NULL

DISTINCT#

DISTINCT removes rows that are identical on all items of the list; two NULLs are considered equal.

SELECT DISTINCT ville FROM client ORDER BY ville;
 ville
-------------
 Alger
 Constantine
 Oran
 NULL
SELECT count(DISTINCT ville) AS villes, count(ville) AS renseignees, count(*) AS clients FROM client;
-- 3 | 5 | 6

Like any text comparison, DISTINCT distinguishes case and accents (35.4): 'a' and 'A' are two values.

SELECT DISTINCT x FROM (VALUES ('a'), ('A'), ('a')) AS v (x) ORDER BY x;     -- A, a

SELECT DISTINCT ON (expr, …) keeps the first row of each group of values of the expressions, according to the ORDER BY; the latter must begin with these expressions (in any order), otherwise 42P10 (SELECT DISTINCT ON expressions must match initial ORDER BY expressions). An expression can be a position (DISTINCT ON (1)) or the alias of a column of the list; the filter comes after GROUP BY, HAVING and the window functions, before LIMIT. Without ORDER BY, the row kept in each group is arbitrary and rows come out in the order of the expressions.

SELECT DISTINCT ON (ville) ville, nom, solde FROM client ORDER BY ville, solde DESC;
 ville       | nom    | solde
-------------+--------+--------
 Alger       | Émile  | 75.25
 Constantine | David  | 250.00
 Oran        | Alice  | 100.50
 NULL        | Fatima | 10.00

VALUES#

A VALUES list is a full query in its own right; its columns are named column1, column2... In a FROM, a table and column alias renames them:

VALUES (1, 'a'), (2, 'b');                                   -- columns column1, column2
SELECT * FROM (VALUES (1, 'un'), (2, 'deux')) AS v (n, mot) ORDER BY n;
 n | mot
---+------
 1 | un
 2 | deux

35.3 FROM and joins#

Join types#

SyntaxSemantics
a, b or a CROSS JOIN bCartesian product
a [INNER] JOIN b ON condthe pairs of rows that satisfy cond
a LEFT [OUTER] JOIN b ON condall rows of a; columns of b set to NULL when there is no match
a RIGHT [OUTER] JOIN b ON condall rows of b; columns of a set to NULL when there is no match
a FULL [OUTER] JOIN b ON condall rows of both sides
a JOIN b USING (col, …)equality on the named columns, merged in the result; also with LEFT, RIGHT, FULL
a NATURAL [LEFT | RIGHT | FULL] JOIN bUSING on all columns with the same name
a, LATERAL (query) x, a {JOIN | LEFT JOIN | CROSS JOIN} LATERAL (query) xsubquery that references the columns of a, evaluated for each row
SELECT c.nom, co.id AS commande, co.date_cmd
FROM client c JOIN commande co ON co.client_id = c.id
ORDER BY co.id;
 nom   | commande | date_cmd
-------+----------+------------
 Alice | 1        | 2026-09-01
 Alice | 2        | 2026-09-15
 Bob   | 3        | 2026-09-20
 Chloé | 4        | 2026-10-02
 Émile | 5        | 2026-10-05

Outer joins#

SELECT c.nom, co.id AS commande
FROM client c LEFT JOIN commande co ON co.client_id = c.id
ORDER BY c.id, co.id;
 nom    | commande
--------+----------
 Alice  | 1
 Alice  | 2
 Bob    | 3
 Chloé  | 4
 David  | NULL
 Émile  | 5
 Fatima | NULL
-- count without losing clients that have no orders
SELECT c.nom, count(co.id) AS commandes
FROM client c LEFT OUTER JOIN commande co ON co.client_id = c.id
GROUP BY c.id, c.nom ORDER BY c.id;
-- Alice 2, Bob 1, Chloé 1, David 0, Émile 1, Fatima 0

-- all articles, ordered or not
SELECT a.code, l.commande_id
FROM ligne_commande l RIGHT JOIN article a ON a.id = l.article_id
ORDER BY a.id, l.commande_id;
-- … GO-01 | 3, TR-01 | NULL

A condition on the optional table does not have the same effect in ON and in WHERE: in ON it restricts the matches (rows without a match are kept, completed with NULL); in WHERE it eliminates those rows, and the outer join behaves like an inner join.

-- clients without a delivered order: the condition is in ON, the WHERE looks for the absence
SELECT c.id, c.nom
FROM client c LEFT JOIN commande co ON co.client_id = c.id AND co.etat = 'livrée'
WHERE co.id IS NULL AND c.id <= 3;                         -- 3 | Chloé

-- condition in WHERE: only clients that have a delivered order remain
SELECT c.id, c.nom, co.id AS commande
FROM client c LEFT JOIN commande co ON co.client_id = c.id
WHERE co.etat = 'livrée' ORDER BY c.id;                    -- 1 | Alice | 1, 2 | Bob | 3

FULL JOIN keeps the rows without a match on both sides:

SELECT c.nom, co.id AS commande
FROM client c FULL JOIN commande co ON co.client_id = c.id AND co.etat = 'livrée'
ORDER BY c.id NULLS LAST, co.id;
 nom    | commande
--------+----------
 Alice  | 1
 Bob    | 3
 Chloé  | NULL
 David  | NULL
 Émile  | NULL
 Fatima | NULL
 NULL   | 2
 NULL   | 4
 NULL   | 5

Cartesian product and join through WHERE#

SELECT count(*) AS n FROM client CROSS JOIN article;      -- 36
SELECT count(*) AS n FROM client, article;                -- 36

SELECT c.nom, co.id
FROM client c, commande co
WHERE co.client_id = c.id AND co.etat = 'ouverte'
ORDER BY co.id;                                            -- Alice | 2, Chloé | 4

Multiple joins#

SELECT c.nom, a.libelle, l.quantite, l.total
FROM client c
JOIN commande co ON co.client_id = c.id
JOIN ligne_commande l ON l.commande_id = co.id
JOIN article a ON a.id = l.article_id
WHERE co.etat = 'livrée'
ORDER BY c.nom, a.libelle;
 nom   | libelle         | quantite | total
-------+-----------------+----------+--------
 Alice | Cahier 96 pages | 2        | 7.00
 Alice | Stylo bille     | 10       | 12.00
 Bob   | Gomme           | 20       | 16.00
 Bob   | Stylo bille     | 100      | 110.00

A column present in several tables must be qualified: WHERE id = 1 in a join of client and commande returns 42702 (Column 'id' in where clause is ambiguous).

USING and NATURAL: merged columns#

CREATE TABLE remise (client_id integer PRIMARY KEY, taux numeric(4,2), id integer);
INSERT INTO remise VALUES (1, 0.10, 100), (4, 0.05, 101), (9, 0.50, 102);

SELECT * FROM commande JOIN remise USING (client_id) ORDER BY 2;
 client_id | id | date_cmd   | etat    | taux | id
-----------+----+------------+---------+------+-----
 1         | 1  | 2026-09-01 | livrée  | 0.10 | 100
 1         | 2  | 2026-09-15 | ouverte | 0.10 | 100
  • SELECT * returns the join column only once, first, then the other columns from the left, then those from the right.
  • The merged column is referenced without qualification (client_id); same-named columns that are not part of the join must still be qualified (commande.id, remise.id).
  • Its value is that of the preserved side: left for JOIN and LEFT JOIN, right for RIGHT JOIN, and the non-NULL value of the two for FULL JOIN:
SELECT client_id, commande.id AS commande, taux
FROM commande FULL JOIN remise USING (client_id)
ORDER BY client_id, commande;
 client_id | commande | taux
-----------+----------+------
 1         | 1        | 0.10
 1         | 2        | 0.10
 2         | 3        | NULL
 3         | 4        | NULL
 4         | NULL     | 0.05
 5         | 5        | NULL
 9         | NULL     | 0.50

NATURAL JOIN joins on all columns with the same name:

CREATE TABLE ville_info (ville varchar(50) PRIMARY KEY, wilaya integer);
INSERT INTO ville_info VALUES ('Oran', 31), ('Alger', 16), ('Annaba', 23);

SELECT ville, nom, wilaya FROM client NATURAL LEFT JOIN ville_info ORDER BY id;
 ville       | nom    | wilaya
-------------+--------+--------
 Oran        | Alice  | 31
 Alger       | Bob    | 16
 Oran        | Chloé  | 31
 Constantine | David  | NULL
 Alger       | Émile  | 16
 NULL        | Fatima | NULL

NATURAL RIGHT JOIN and NATURAL FULL JOIN work the same way (Annaba | NULL | 23 then appears). NATURAL CROSS JOIN does not exist (42601).

Self-join#

A table is joined to itself under two aliases:

SELECT a.nom, b.nom AS meme_ville
FROM client a JOIN client b ON a.ville = b.ville AND a.id < b.id
ORDER BY a.id;
 nom   | meme_ville
-------+------------
 Alice | Chloé
 Bob   | Émile

Derived table#

A subquery in FROM acts as a table. It must have an alias (PostgreSQL 16 makes it optional; MIRAJ answers 42601, syntax error at end of input). A column alias can follow.

SELECT t.ville, t.total
FROM (SELECT ville, sum(solde) AS total FROM client GROUP BY ville) AS t (ville, total)
WHERE total > 100
ORDER BY 1;
 ville       | total
-------------+--------
 Constantine | 250.00
 Oran        | 100.50

Column aliases#

A column alias AS x (a, b, …) is written after any source of the FROM: named table, view, WITH expression, derived table (even one whose list is *), VALUES list, function. As in PostgreSQL, the first columns are renamed and the following ones keep their name:

SELECT * FROM article AS q (x, y) WHERE x = 1;
 x | y     | libelle     | prix | stock | actif
---+-------+-------------+------+-------+-------
 1 | ST-01 | Stylo bille | 1.20 | 500   | t
SELECT * FROM (SELECT * FROM remise) AS r (a, b) ORDER BY a;     -- columns a, b, id
SELECT * FROM remise AS r (a, b, c, d);
-- ERROR 42P10: table "r" has 3 columns available but 4 columns specified

LATERAL#

A subquery preceded by LATERAL can reference the columns of the tables written before it; it is evaluated for each row. Typical use: "the latest order of each client".

SELECT c.nom, d.id, d.date_cmd
FROM client c,
     LATERAL (SELECT id, date_cmd FROM commande co
              WHERE co.client_id = c.id ORDER BY date_cmd DESC LIMIT 1) d
ORDER BY c.id;
 nom   | id | date_cmd
-------+----+------------
 Alice | 2  | 2026-09-15
 Bob   | 3  | 2026-09-20
 Chloé | 4  | 2026-10-02
 Émile | 5  | 2026-10-05

With LEFT JOIN LATERAL (…) d ON true, clients without orders are kept (David | NULL, Fatima | NULL). JOIN LATERAL … ON true and CROSS JOIN LATERAL are equivalent to the comma.

Limits: LATERAL only precedes a subquery; it is allowed neither with RIGHT or FULL JOIN, nor with USING or NATURAL (0A000). Since the subquery is replanned for each row, reserve LATERAL for left tables of moderate size; for a "first per group" on a large table, prefer row_number() (35.10).

generate_series#

generate_series(start, stop [, step]) returns a sequence of integers, or of dates and timestamps with an interval step:

SELECT n, n * n AS carre FROM generate_series(1, 4) AS s (n);
 n | carre
---+-------
 1 | 1
 2 | 4
 3 | 9
 4 | 16
SELECT g FROM generate_series(10, 1, -3) AS g;                               -- 10, 7, 4, 1
SELECT date '2026-10-01' + n AS jour FROM generate_series(0, 2) AS s (n);    -- three dates
SELECT * FROM generate_series(date '2026-10-01', date '2026-10-03', interval '1 day') AS j;
-- 2026-10-01, 2026-10-02, 2026-10-03
SELECT * FROM generate_series(timestamp '2026-10-01 00:00', timestamp '2026-10-01 02:00',
                              interval '1 hour') AS j;
-- 2026-10-01 00:00:00, 2026-10-01 01:00:00, 2026-10-01 02:00:00

A sequence is not bounded by cte_max_recursion_depth (the limit of recursive queries, see 35.9): generate_series(1, 2000) returns its 2,000 values. Its own limit is 10,000,000 values (54000: generate_series cannot return more than 10000000 rows). A zero step returns 22023 (step size cannot equal zero). The bounds are constants: they do not reference another table of the FROM (42703).

Set-returning functions in the SELECT list#

generate_series, unnest, regexp_matches, regexp_split_to_table, json[b]_array_elements[_text] and json[b]_object_keys can also be written in the list of a SELECT, as in PostgreSQL 10 and later: the query returns one row per value, each source row being repeated. The column is named after the function, or its alias.

SELECT generate_series(1, 3);                                -- column generate_series: 1, 2, 3
SELECT generate_series(1, 3) * 10 AS dizaine;                -- 10, 20, 30
SELECT nom, generate_series(1, 2) AS tour FROM client WHERE id <= 2 ORDER BY nom, tour;
 nom   | tour
-------+------
 Alice | 1
 Alice | 2
 Bob   | 1
 Bob   | 2

Several functions in the same list advance together: the longest sets the number of rows, the others are padded with NULL.

SELECT generate_series(1, 3) AS a, generate_series(1, 2) AS b;
 a | b
---+------
 1 | 1
 2 | 2
 3 | NULL

The arguments of generate_series there are constants (a column: 0A000, generate_series citing a column in the select list); unnest(ARRAY[…]) can reference the columns of the row (SELECT nom, unnest(ARRAY[solde, solde * 2]) AS v FROM client). The form also works in INSERT … SELECT, a union or a subquery; it is not allowed in a grouped, aggregated or windowed list (0A000).

Function in a FROM#

A function, stored or provided by the server, can be called in a FROM. A scalar function returns one row of one column there, as in PostgreSQL; this is the form sent by psycopg2's cursor.callproc.

CREATE FUNCTION total_client(cid integer) RETURNS numeric(12,2) AS $$
  SELECT coalesce(sum(l.total), 0)
  FROM commande co JOIN ligne_commande l ON l.commande_id = co.id
  WHERE co.client_id = cid
$$ LANGUAGE sql;

SELECT * FROM total_client(3);                        -- column "total_client": 31.50
SELECT * FROM total_client(3) AS t;                   -- column "t"
SELECT t.montant FROM total_client(3) AS t (montant); -- column "montant"
SELECT c.nom, t FROM client c, total_client(c.id) AS t ORDER BY c.nom;
 nom    | t
--------+--------
 Alice  | 25.90
 Bob    | 126.00
 Chloé  | 31.50
 David  | 0.00
 Fatima | 0.00
 Émile  | 0.00

The column is named after the alias, otherwise after the function. A scalar function sees the tables that precede it in the FROM (last example), without having to write LATERAL; the word is allowed in front of it, in first position too (FROM LATERAL f(…)). A list of column definitions (AS t (a int, b text)) returns 0A000.

Stored set-returning functions. A function declared RETURNS TABLE (…), RETURNS SETOF type, RETURNS SETOF table (or with OUT parameters and SETOF record) returns rows, read like those of a table (see 39. Routines and triggers (PL/pgSQL) for RETURN NEXT and RETURN QUERY):

CREATE FUNCTION commandes_de(cid integer) RETURNS TABLE (num integer, jour date, etat varchar) AS $$
  SELECT id, date_cmd, etat FROM commande WHERE client_id = cid ORDER BY id
$$ LANGUAGE sql;

SELECT * FROM commandes_de(1);
 num | jour       | etat
-----+------------+---------
 1   | 2026-09-01 | livrée
 2   | 2026-09-15 | ouverte
CREATE FUNCTION articles_actifs() RETURNS SETOF article AS $$
  SELECT * FROM article WHERE actif ORDER BY id
$$ LANGUAGE sql;

SELECT code, prix FROM articles_actifs() WHERE prix > 2;     -- CA-01 3.50, CL-01 6.90, TR-01 2.10

Such a function is called in a FROM, with constant arguments: called in the list of a SELECT (0A000: set-valued function called in context that cannot accept a set), with arguments that reference another table of the FROM, or followed by WITH ORDINALITY, it is refused (0A000).

Server functions that return several rows. They accept WITH ORDINALITY, which adds the rank of each row (column ordinality, or the last name of the alias):

FunctionColumns
generate_series(start, stop [, step])the value
unnest(array)one element per row
regexp_split_to_table(text, pattern [, flags])one piece per row
regexp_matches(text, pattern [, flags])an array of the groups per match
json_each, jsonb_each, json_each_text, jsonb_each_textkey, value
json[b]_array_elements, json[b]_array_elements_textvalue
json[b]_object_keysthe key
SELECT * FROM unnest(ARRAY['rouge', 'vert']) WITH ORDINALITY AS c (couleur, rang);
-- rouge | 1 ; vert | 2
SELECT c.nom, v FROM client c, unnest(ARRAY[c.solde, c.solde * 2]) AS v
WHERE c.id <= 2 ORDER BY c.nom, v;
-- Alice 100.50, Alice 201.00, Bob -40.00, Bob -20.00
SELECT jour, n FROM generate_series(1, 3) WITH ORDINALITY AS g (jour, n);
SELECT * FROM regexp_split_to_table('a,b;c', '[,;]') WITH ORDINALITY AS t (morceau, n);
-- a | 1 ; b | 2 ; c | 3
SELECT * FROM jsonb_each('{"b": 2, "a": 1}') AS e;           -- a | 1 ; b | 2 (keys sorted)
SELECT * FROM json_each_text('{"b": 2, "a": "x"}') AS e (k, v);   -- b | 2 ; a | x
SELECT c.nom, e.key FROM client c, jsonb_each(jsonb_build_object('id', c.id)) AS e
WHERE c.id = 1;                                               -- Alice | id

JSON functions are lateral over the tables that precede them (LATERAL written or not, LEFT JOIN LATERAL … ON true included); a NULL document returns no rows.

unnest expands an array built with ARRAY[a, b, …], whose elements can reference the columns of the preceding tables (cast or not: ARRAY[…]::int[] casts each element; ARRAY[]::int[] returns no rows), and an array written as text {a,b}: bound parameter (unnest($1), unnest($1::int[]), the form drivers send for a bound list, in text format) or literal (unnest('{1,2,NULL}'::int[])). Without ::type[], the elements of a {…} text remain text; a NULL element returns NULL, a NULL or {} array no rows. The array of a column (unnest(t.col)), ARRAY[] without a cast and an array parameter sent in binary return 0A000.

FROM ONLY table and FROM table * are accepted: ONLY and * have no effect, since MIRAJ has no table inheritance.


35.4 WHERE#

WHERE filters the rows produced by FROM, before any grouping.

FormExample
comparisons =, <>, !=, <, <=, >, >=solde >= 10 AND solde <> 75.25
AND, OR, NOTsolde != 0 AND NOT (ville = 'Oran')
[NOT] BETWEEN a AND b (bounds included)solde BETWEEN 0 AND 100
[NOT] IN (list)ville IN ('Oran', 'Constantine')
= ANY (ARRAY[…]), <> ALL (ARRAY[…]), > ALL (ARRAY[…])ville = ANY (ARRAY['Oran', 'Constantine'])
IS [NOT] NULLemail IS NOT NULL
IS [NOT] DISTINCT FROMville IS DISTINCT FROM 'Oran'
[NOT] LIKE, [NOT] ILIKE, ESCAPEnom LIKE 'A%'
~, ~*, !~, !~* (regular expressions)nom ~ '^[a-c]'
bare boolean, IS [NOT] TRUE, IS [NOT] FALSEWHERE actif, WHERE NOT actif
row comparison(ville, nom) IN (('Oran', 'Alice'), ('Alger', 'Bob')), (id, solde) > (3, 100)
subqueries (35.7)id IN (SELECT …), EXISTS (SELECT …)
SELECT nom, solde FROM client WHERE solde >= 10 AND solde <> 75.25 ORDER BY id;
-- Alice 100.50, David 250.00, Fatima 10.00

SELECT libelle FROM article WHERE actif IS TRUE AND stock > 100 ORDER BY id;
-- Stylo bille, Cahier 96 pages, Gomme

SELECT id FROM commande WHERE date_cmd BETWEEN date '2026-09-01' AND date '2026-09-30' ORDER BY id;   -- 1, 2, 3
SELECT id FROM commande WHERE date_cmd > date '2026-10-05' - interval '1 month' ORDER BY id;          -- 2, 3, 4, 5
SELECT id FROM commande WHERE extract(month FROM date_cmd) = 9 ORDER BY id;                           -- 1, 2, 3

AND takes precedence over OR: solde > 0 OR ville = 'Oran' AND email IS NULL reads as solde > 0 OR (ville = 'Oran' AND email IS NULL). An alias from the select list is not visible in WHERE (42703), as in PostgreSQL.

NULL and three-valued logic#

A comparison with NULL is neither true nor false but NULL, and WHERE keeps only the rows whose condition is true:

SELECT NULL = NULL AS a, NULL IS NULL AS b, 1 = NULL AS c, NULL AND false AS d,
       NULL OR true AS e, NOT NULL AS f, 1 IN (2, NULL) AS g, 1 NOT IN (2, NULL) AS h;
 a    | b | c    | d | e | f    | g    | h
------+---+------+---+---+------+------+------
 NULL | t | NULL | f | t | NULL | NULL | NULL

Practical consequences:

SELECT nom FROM client WHERE ville <> 'Oran' ORDER BY id;                -- Bob, David, Émile
SELECT nom FROM client WHERE ville IS DISTINCT FROM 'Oran' ORDER BY id;  -- Bob, David, Émile, Fatima
SELECT nom FROM client WHERE ville IS NULL;                              -- Fatima

Fatima, whose city is NULL, is returned only by IS DISTINCT FROM, which treats NULL as a value. x NOT IN (subquery) returns nothing as soon as the subquery contains a NULL (35.7).

Text: case, accents, LIKE and regular expressions#

Text is compared byte by byte ("C" collation, like PostgreSQL with C.UTF-8): =, <, IN, LIKE, ~, DISTINCT, GROUP BY, ORDER BY, joins and uniqueness constraints distinguish case and accents. Trailing spaces are ignored by =. ILIKE and ~* ignore case.

SELECT 'a' = 'A' AS casse, 'e' = 'é' AS accent, 'a' = 'a ' AS espace, 'a' < 'B' AS ordre;
 casse | accent | espace | ordre
-------+--------+--------+-------
 f     | f      | t      | f
SELECT nom FROM client WHERE nom = 'emile';                              -- no rows
SELECT nom FROM client WHERE nom = 'Émile';                              -- Émile
OperatorOn MIRAJ
LIKE, NOT LIKE (~~, !~~)%: any sequence of characters, _: one character; case- and accent-sensitive
ILIKE, NOT ILIKE (~~*, !~~*)like LIKE, case and accents ignored
~, !~regular expression, case- and accent-sensitive
~*, !~*like ~, case ignored (accents are still distinguished)
SELECT nom FROM client WHERE nom LIKE 'A%';                 -- Alice
SELECT nom FROM client WHERE nom LIKE 'a%';                 -- no rows
SELECT nom FROM client WHERE nom LIKE '_o_';                -- Bob
SELECT nom FROM client WHERE nom ILIKE 'ALI%';              -- Alice
SELECT nom FROM client WHERE nom ILIKE 'emile';             -- Émile
SELECT nom FROM client WHERE nom ~ '^[A-C]' ORDER BY id;    -- Alice, Bob, Chloé
SELECT nom FROM client WHERE nom ~* '^[a-c]' ORDER BY id;   -- Alice, Bob, Chloé
SELECT nom FROM client WHERE nom ~* '^e';                   -- no rows
SELECT nom FROM client WHERE nom !~ 'e$' ORDER BY id;       -- Bob, Chloé, David, Fatima
SELECT nom FROM client WHERE nom !~~* 'b%' ORDER BY id;     -- Alice, Chloé, David, Émile, Fatima

The LIKE escape character is the backslash, or the one given by ESCAPE:

CREATE TABLE fichier (nom text);
INSERT INTO fichier VALUES ('rapport_2026.pdf'), ('rapport-2026.pdf'), ('100%.txt'), ('Rapport_final.PDF');

SELECT nom FROM fichier WHERE nom ILIKE 'rapport\_%' ORDER BY nom;           -- Rapport_final.PDF, rapport_2026.pdf
SELECT nom FROM fichier WHERE nom LIKE 'rapport#_%' ESCAPE '#' ORDER BY nom; -- rapport_2026.pdf
SELECT nom FROM fichier WHERE nom LIKE '100\%%';                             -- 100%.txt
SELECT nom FROM fichier WHERE nom ILIKE '%.pdf' ORDER BY nom;                -- 3 rows, including Rapport_final.PDF
SELECT nom FROM fichier WHERE nom LIKE '%.pdf' ORDER BY nom;                 -- rapport-2026.pdf, rapport_2026.pdf
SELECT nom FROM fichier WHERE nom ~ '^rapport' ORDER BY nom;                 -- rapport-2026.pdf, rapport_2026.pdf

Collations. COLLATE "C" (like "POSIX" and ucs_basic, qualified or not with pg_catalog) compares byte by byte, like the default. COLLATE "default" and the other PostgreSQL collations ("fr_FR"...) are accepted and have no effect. For a comparison that ignores case and accents, apply the MIRAJ collation utf8mb4_general_ci, or compare lower(…):

SELECT nom FROM client WHERE nom COLLATE "C" = 'alice';                     -- no rows
SELECT nom FROM client WHERE nom = 'alice' COLLATE utf8mb4_general_ci;      -- Alice
SELECT nom FROM client WHERE lower(nom) = 'alice';                          -- Alice

Comparing a number and a text#

A text compared with a numeric column is read as a number (WHERE id = '1', WHERE solde = '100.5'). A text that is not a number is an error, as in PostgreSQL:

SELECT nom FROM client WHERE solde = 'abc';
-- ERROR 22P02: invalid input syntax for type numeric: "abc"

35.5 GROUP BY and HAVING#

GROUP BY groups the rows that have the same values for the listed expressions; aggregates compute one value per group. HAVING filters the groups, after the computation.

SELECT ville, count(*) AS n, sum(solde) AS total, avg(solde) AS moyenne,
       min(solde) AS mini, max(solde) AS maxi
FROM client GROUP BY ville ORDER BY ville;
 ville       | n | total  | moyenne              | mini   | maxi
-------------+---+--------+----------------------+--------+--------
 Alger       | 2 | 55.25  | 27.6250000000000000  | -20.00 | 75.25
 Constantine | 1 | 250.00 | 250.0000000000000000 | 250.00 | 250.00
 Oran        | 2 | 100.50 | 50.2500000000000000  | 0.00   | 100.50
 NULL        | 1 | 10.00  | 10.0000000000000000  | 10.00  | 10.00

NULLs form one group. avg of integers or decimals is a numeric with sixteen decimals (round(avg(prix), 2) for display); sum of an integer is a bigint.

SELECT ville, sum(solde) AS total FROM client
WHERE solde > 0                    -- filters the rows, before grouping
GROUP BY ville
HAVING sum(solde) > 50             -- filters the groups
ORDER BY total DESC;
 ville       | total
-------------+--------
 Constantine | 250.00
 Oran        | 100.50
 Alger       | 75.25
-- revenue per client
SELECT c.nom, count(DISTINCT co.id) AS commandes, sum(l.total) AS montant
FROM client c
JOIN commande co ON co.client_id = c.id
JOIN ligne_commande l ON l.commande_id = co.id
GROUP BY c.id, c.nom
ORDER BY montant DESC;
 nom   | commandes | montant
-------+-----------+---------
 Bob   | 1         | 126.00
 Chloé | 1         | 31.50
 Alice | 2         | 25.90

What GROUP BY accepts#

An expression, an alias from the select list or the position of an item of the list:

SELECT extract(month FROM date_cmd) AS mois, count(*) AS n
FROM commande GROUP BY extract(month FROM date_cmd) ORDER BY mois;      -- 9 | 3, 10 | 2

SELECT extract(month FROM date_cmd) AS mois, count(*) AS n
FROM commande GROUP BY mois ORDER BY mois;                              -- alias

SELECT to_char(date_cmd, 'YYYY-MM') AS mois, count(*) AS n
FROM commande GROUP BY 1 ORDER BY 1;                                    -- position: 2026-09 | 3, 2026-10 | 2

Column outside GROUP BY#

Every column of the list that is not inside an aggregate must appear in GROUP BY:

SELECT ville, nom FROM client GROUP BY ville;
-- ERROR 42803: 'nom' isn't in GROUP BY

The primary key makes it unnecessary to list the other columns of its table (functional dependency, as in PostgreSQL): when GROUP BY contains all the primary key columns of a table, its other columns are allowed in the list, in HAVING and in ORDER BY, table.* included.

SELECT c.id, c.nom, c.ville, count(o.id) AS commandes
FROM client c LEFT JOIN commande o ON o.client_id = c.id
GROUP BY c.id
ORDER BY c.nom;

This only holds for a table read directly and its whole primary key: columns of another table in the join, a composite primary key listed in part, a UNIQUE constraint, a view or a derived table remain subject to the rule (42803).

Without GROUP BY#

An aggregate without GROUP BY returns a single row for the whole table, even an empty one; HAVING can then discard it:

SELECT count(*) AS c, sum(solde) AS s, avg(solde) AS a FROM client WHERE id > 100;   -- 0 | NULL | NULL
SELECT count(*) AS n FROM client HAVING count(*) > 100;                              -- no rows

Aggregates#

AggregatePurpose
count(*), count(expr), count(DISTINCT expr)rows, non-NULL values, distinct values
sum, avg, min, maxsum(integer): bigint; avg: numeric
stddev, variance, stddev_pop, var_pop, stddev_samp, var_sampstandard deviation and variance
string_agg(expr, separator [ORDER BY …])concatenation
array_agg(expr [ORDER BY …]), array_agg(DISTINCT expr)array of the values ({1,2,NULL}), NULL on zero rows
bool_and, bool_or, everyconjunction, disjunction
json_agg, jsonb_agg, json_object_agg, jsonb_object_aggJSON documents
percentile_cont(f), percentile_disc(f), mode() followed by WITHIN GROUP (ORDER BY …)continuous percentile, discrete percentile, most frequent value (22023 outside 0..1)
rank(v), dense_rank(v), percent_rank(v), cume_dist(v) followed by WITHIN GROUP (ORDER BY …)rank the value v would have
corr, covar_pop, covar_samp, regr_slope, regr_intercept, regr_r2, regr_count, regr_avgx, regr_avgy, regr_sxx, regr_syy, regr_sxystatistics on non-NULL pairs
SELECT ville, string_agg(nom, ', ' ORDER BY nom) AS noms FROM client GROUP BY ville ORDER BY ville;
 ville       | noms
-------------+--------------
 Alger       | Bob, Émile
 Constantine | David
 Oran        | Alice, Chloé
 NULL        | Fatima

The FILTER (WHERE …) clause restricts the rows seen by an aggregate:

SELECT ville,
       count(*) FILTER (WHERE solde > 0) AS crediteurs,
       sum(solde) FILTER (WHERE solde > 50) AS gros
FROM client GROUP BY ville ORDER BY ville;
 ville       | crediteurs | gros
-------------+------------+--------
 Alger       | 1          | 75.25
 Constantine | 1          | 250.00
 Oran        | 1          | 100.50
 NULL        | 1          | NULL
SELECT ville, array_agg(id ORDER BY id) AS ids FROM client GROUP BY ville ORDER BY ville;
 ville       | ids
-------------+-------
 Alger       | {2,5}
 Constantine | {4}
 Oran        | {1,3}
 NULL        | {6}
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY solde) AS mediane,
       percentile_disc(0.5) WITHIN GROUP (ORDER BY solde) AS mediane_d,
       rank(100) WITHIN GROUP (ORDER BY solde DESC) AS rang_de_100
FROM client;
 mediane | mediane_d | rang_de_100
---------+-----------+-------------
 42.625  | 10.00     | 3

Aggregates are detailed in chapter 36. SQL functions.

ROLLUP, CUBE, GROUPING SETS#

SELECT ville, etat, count(*) AS n
FROM client c JOIN commande co ON co.client_id = c.id
GROUP BY ROLLUP (ville, etat)
ORDER BY ville NULLS LAST, etat NULLS LAST;
 ville | etat    | n
-------+---------+---
 Alger | annulée | 1
 Alger | livrée  | 1
 Alger | NULL    | 2
 Oran  | livrée  | 1
 Oran  | ouverte | 2
 Oran  | NULL    | 3
 NULL  | NULL    | 5

ROLLUP (a, b) adds a subtotal per a and a grand total; CUBE (a, b) also adds the subtotals per b (ten rows here); GROUPING SETS ((ville), (etat), ()) returns only the requested groupings. The grouping(col) function is 1 on a total row where col is aggregated, which distinguishes it from a real NULL:

SELECT ville, count(*) AS n, grouping(ville) AS g FROM client GROUP BY ROLLUP (ville) ORDER BY g, ville;
 ville       | n | g
-------------+---+---
 Alger       | 2 | 0
 Constantine | 1 | 0
 Oran        | 2 | 0
 NULL        | 1 | 0
 NULL        | 6 | 1

35.6 ORDER BY, LIMIT, OFFSET, FETCH FIRST#

ORDER BY#

Without ORDER BY, the row order is not guaranteed. A sort key is an expression, an alias from the list or the position of an item; ASC (default) or DESC.

SELECT nom, solde FROM client ORDER BY solde DESC, nom;                   -- David, Alice, Émile, Fatima, Chloé, Bob
SELECT nom, solde * 2 AS double FROM client ORDER BY double DESC LIMIT 2; -- alias
SELECT nom, solde FROM client ORDER BY 2 DESC LIMIT 2;                    -- position
SELECT nom FROM client ORDER BY length(nom), nom;                         -- expression
SELECT ville, count(*) AS n FROM client GROUP BY ville ORDER BY count(*) DESC, ville;   -- aggregate

Position of NULLs. As in PostgreSQL, NULLs come last in ascending order and first in descending order; NULLS FIRST and NULLS LAST force their position.

SELECT nom, ville FROM client ORDER BY ville, nom;
 nom    | ville
--------+-------------
 Bob    | Alger
 Émile  | Alger
 David  | Constantine
 Alice  | Oran
 Chloé  | Oran
 Fatima | NULL
SELECT nom, ville FROM client ORDER BY ville NULLS FIRST, nom;        -- Fatima first
SELECT nom, ville FROM client ORDER BY ville DESC NULLS LAST, nom;    -- Alice, Chloé, David, Bob, Émile, Fatima

Text order. Sorting follows byte order ("C" collation, like PostgreSQL with C.UTF-8): uppercase before lowercase, accented letters after unaccented letters.

SELECT nom FROM client ORDER BY nom;                     -- Alice, Bob, Chloé, David, Fatima, Émile
SELECT x FROM (VALUES ('b'), ('B'), ('a'), ('A'), ('é'), ('e')) AS v (x) ORDER BY x;
-- A, B, a, b, e, é

For an order that ignores case and accents, sort by x COLLATE utf8mb4_general_ci (a MIRAJ collation), then by x to break ties: … ORDER BY x COLLATE utf8mb4_general_ci, x returns A, a, B, b, e, é.

LIMIT and OFFSET#

SELECT id FROM client ORDER BY id LIMIT 2;                -- 1, 2
SELECT id FROM client ORDER BY id LIMIT 2 OFFSET 3;       -- 4, 5
SELECT id FROM client ORDER BY id OFFSET 4;               -- 5, 6   (OFFSET alone)
SELECT id FROM client ORDER BY id OFFSET 1 LIMIT 2;       -- 2, 3   (OFFSET before LIMIT)
SELECT id FROM client ORDER BY id LIMIT ALL OFFSET 5;     -- 6
SELECT id FROM client ORDER BY id LIMIT 0;                -- no rows

LIMIT ALL and LIMIT NULL do not limit. The value is a written integer or a parameter, not an expression (LIMIT 1 + 1: 42601):

PREPARE page (int, int) AS SELECT id FROM client ORDER BY id LIMIT $1 OFFSET $2;
EXECUTE page(2, 1);                                       -- 2, 3

For stable pagination, sort on a unique key (ORDER BY date_cmd, id).

FETCH FIRST#

Standard form, equivalent to LIMIT:

SELECT id FROM client ORDER BY id FETCH FIRST 2 ROWS ONLY;                    -- 1, 2
SELECT id FROM client ORDER BY id OFFSET 2 ROWS FETCH NEXT 2 ROWS ONLY;       -- 3, 4
SELECT id FROM client ORDER BY id FETCH FIRST ROW ONLY;                       -- 1

WITH TIES instead of ONLY also returns the rows that tie with the last one, according to the ORDER BY (mandatory: without it, 42601 FETCH ... WITH TIES requires ORDER BY clause to be present):

SELECT ville, nom FROM client ORDER BY ville FETCH FIRST 1 ROWS WITH TIES;
 ville | nom
-------+-------
 Alger | Bob
 Alger | Émile

35.7 Subqueries#

FormPurpose
(SELECT …) in place of a valuescalar subquery: one column, at most one row; NULL when there is no row
x [NOT] IN (SELECT …), (a, b) IN (SELECT …, …)membership
[NOT] EXISTS (SELECT …)existence of at least one row
x op ANY (SELECT …), x op SOME (SELECT …), x op ALL (SELECT …)comparison with any or all of the values
(SELECT …) alias in FROMderived table (35.3)

A subquery is correlated when it references a column of the enclosing query; it is then evaluated for each row.

-- scalar, uncorrelated
SELECT nom, solde FROM client WHERE solde > (SELECT avg(solde) FROM client) ORDER BY id;
-- Alice 100.50, David 250.00, Émile 75.25

-- scalar, correlated, in the list
SELECT nom, (SELECT count(*) FROM commande co WHERE co.client_id = c.id) AS commandes,
            (SELECT max(date_cmd) FROM commande co WHERE co.client_id = c.id) AS derniere
FROM client c ORDER BY c.id;
 nom    | commandes | derniere
--------+-----------+------------
 Alice  | 2         | 2026-09-15
 Bob    | 1         | 2026-09-20
 Chloé  | 1         | 2026-10-02
 David  | 0         | NULL
 Émile  | 1         | 2026-10-05
 Fatima | 0         | NULL
SELECT nom FROM client WHERE id IN (SELECT client_id FROM commande WHERE etat = 'livrée') ORDER BY id;
-- Alice, Bob

SELECT nom FROM client c
WHERE EXISTS (SELECT 1 FROM commande co WHERE co.client_id = c.id AND co.etat = 'ouverte') ORDER BY id;
-- Alice, Chloé

SELECT nom FROM client c
WHERE NOT EXISTS (SELECT 1 FROM commande co WHERE co.client_id = c.id) ORDER BY id;
-- David, Fatima

SELECT libelle, prix FROM article WHERE prix >= ALL (SELECT prix FROM article);     -- Agrafeuse 14.00
SELECT libelle, prix FROM article
WHERE prix > ANY (SELECT prix_unit FROM ligne_commande WHERE commande_id = 4) ORDER BY id;
-- Classeur 6.90, Agrafeuse 14.00

SELECT nom FROM client WHERE (id, ville) IN (SELECT client_id, 'Oran' FROM commande) ORDER BY id;
-- Alice, Chloé

-- the biggest line of each article
SELECT a.libelle, l.quantite
FROM article a JOIN ligne_commande l ON l.article_id = a.id
WHERE l.quantite = (SELECT max(l2.quantite) FROM ligne_commande l2 WHERE l2.article_id = a.id)
ORDER BY a.id;

EXISTS placed in the list is a boolean (t / f). A subquery can have ORDER BY and LIMIT (id IN (SELECT client_id FROM commande ORDER BY date_cmd DESC LIMIT 2)).

NOT IN and NULL. If the subquery returns a NULL, x NOT IN (…) is never true and the query returns no rows (as in PostgreSQL). Prefer NOT EXISTS, which does not have this trap.

QueryResponse
scalar subquery that returns several rows: SELECT (SELECT nom FROM client)21000: Subquery returns more than 1 row
scalar subquery with several columns: SELECT (SELECT nom, ville FROM client WHERE id = 1)42601: Operand should contain 1 column(s)
row compared to a subquery with =: (id, ville) = (SELECT client_id, 'Oran' FROM …)42601: Operand should contain 1 column(s); write IN

35.8 Set operations: UNION, INTERSECT, EXCEPT#

query {UNION | INTERSECT | EXCEPT} [ALL | DISTINCT] query [...]
    [ORDER BY ...] [LIMIT n] [OFFSET m]
OperatorResult
UNIONrows of either query, without duplicates
UNION ALLall rows, duplicates kept
INTERSECTrows present on both sides, without duplicates
INTERSECT ALLrows present on both sides, as many times as on the side that has the fewest
EXCEPTrows on the left absent from the right, without duplicates
EXCEPT ALLrows on the left, minus one occurrence per equal row on the right

With client.ville (Oran x2, Alger x2, Constantine, NULL) and ville_info.ville (Oran, Alger, Annaba):

SELECT ville FROM client UNION SELECT ville FROM ville_info ORDER BY ville;
-- Alger, Annaba, Constantine, Oran, NULL
SELECT ville FROM client INTERSECT SELECT ville FROM ville_info ORDER BY ville;
-- Alger, Oran
SELECT ville FROM client EXCEPT SELECT ville FROM ville_info ORDER BY ville;
-- Constantine, NULL
SELECT ville FROM client EXCEPT ALL SELECT ville FROM ville_info ORDER BY ville;
-- Alger, Constantine, Oran, NULL
  • The queries must return the same number of columns (42601: The used SELECT statements have a different number of columns); the names are those of the first.
  • Two NULLs are equal for duplicate elimination; text is compared byte by byte (SELECT nom FROM client UNION SELECT 'alice' returns Alice and alice).
  • ORDER BY, LIMIT and OFFSET written at the end apply to the whole result; the sort key is a column name, a position or an expression on these columns.
  • Precedence: INTERSECT is evaluated before UNION and EXCEPT, as in PostgreSQL; parentheses force another order, and allow an ORDER BY / LIMIT specific to one branch:
SELECT 1 AS n UNION ALL SELECT 2 INTERSECT SELECT 2;          -- 1, 2
(SELECT 1 AS n UNION ALL SELECT 2) INTERSECT SELECT 2;        -- 2

(SELECT nom FROM client ORDER BY solde DESC LIMIT 1)
UNION ALL
(SELECT nom FROM client ORDER BY solde LIMIT 1);              -- David, Bob

Column types. An integer and a decimal give a decimal (SELECT 1 UNION SELECT 2.5: 1.0, 2.5). An untyped text literal ('2', '24:00:00') takes, as in PostgreSQL, the type of the queries resolved before it, pairwise from left to right (that of the following query if it is the first): integer (smallint, integer or bigint, whose bounds are checked), numeric, float, boolean, date, time, timestamp, timestamptz, bytea, uuid, json, jsonb, interval or enumeration; a text that cannot be read in that type is refused, as by a ::type conversion (22P02; 22003 outside the bounds of an integer; 22007 or 22008 for a date, a time or an interval). The same goes for the rows of a VALUES list and the recursive term of a WITH RECURSIVE, read in the type of the starting term; a starting term made of untyped texts is a text, which the recursive term cannot change into another type (42804).

SELECT 1 UNION SELECT '2';                                    -- 1, 2 (integer)
SELECT 1 UNION SELECT 'x';                                    -- 22P02
SELECT 1 UNION SELECT '99999999999';                          -- 22003 (integer)
SELECT '1.5' UNION SELECT 1 UNION SELECT 2.5;                 -- 22P02 ('1.5' read as an integer)
SELECT INTERVAL '1 day' UNION SELECT '24:00:00';              -- 1 day (interval)
VALUES (DATE '2026-01-02'), ('2026-03-04');                   -- two dates

A typed text (text column, 'x'::text) matches, as in PostgreSQL, only another text: against a number, a boolean, a date, a bytea, a uuid, a JSON document, an interval or an enumeration, the query is refused (42804: UNION types integer and text cannot be matched).


35.9 Common table expressions: WITH#

A WITH clause names one or more queries, usable as tables in the main query and in the expressions that follow them.

WITH total_cmd AS (
    SELECT commande_id, sum(total) AS montant FROM ligne_commande GROUP BY commande_id
), par_client AS (
    SELECT co.client_id, sum(t.montant) AS montant
    FROM commande co JOIN total_cmd t ON t.commande_id = co.id
    GROUP BY co.client_id
)
SELECT c.nom, p.montant
FROM client c JOIN par_client p ON p.client_id = c.id
ORDER BY p.montant DESC;
 nom   | montant
-------+---------
 Bob   | 126.00
 Chloé | 31.50
 Alice | 25.90
  • A column list renames the result: WITH v (n, carre) AS (SELECT id, id * id FROM client).
  • An expression can be referenced several times (FROM x AS p JOIN x AS q ON …).
  • An expression named like an existing table hides it in the query (WITH client AS (SELECT 1 AS id) SELECT * FROM client returns one row).
  • AS MATERIALIZED and AS NOT MATERIALIZED are accepted.
  • WITH is used in a subquery, in a derived table, in the query of an INSERT … SELECT and in a view.

WITH can also be written at the head of an INSERT, an UPDATE or a DELETE, and an expression of the clause that opens the statement can itself be a write (WITH d AS (DELETE … RETURNING *) SELECT … FROM d): it runs once, its name designates the rows returned. See 34. DML language.

Recursive expressions#

WITH RECURSIVE defines an expression that references itself: a starting term, UNION ALL (or UNION, which discards rows already produced), then a recursive term.

WITH RECURSIVE r (n) AS (
    SELECT 1
    UNION ALL
    SELECT n + 1 FROM r WHERE n < 5
)
SELECT n FROM r ORDER BY n;                                   -- 1, 2, 3, 4, 5

Walking a hierarchy:

CREATE TABLE categorie (
    id        integer PRIMARY KEY,
    parent_id integer REFERENCES categorie (id),
    nom       text NOT NULL
);
INSERT INTO categorie VALUES
    (1, NULL, 'Papeterie'), (2, 1, 'Écriture'), (3, 1, 'Classement'), (4, 2, 'Stylos'), (5, 2, 'Crayons');

WITH RECURSIVE arbre (id, nom, niveau, chemin) AS (
    SELECT id, nom, 0, nom FROM categorie WHERE parent_id IS NULL
    UNION ALL
    SELECT c.id, c.nom, a.niveau + 1, a.chemin || ' > ' || c.nom
    FROM categorie c JOIN arbre a ON c.parent_id = a.id
)
SELECT id, niveau, chemin FROM arbre ORDER BY chemin;
 id | niveau | chemin
----+--------+--------------------------------
 1  | 0      | Papeterie
 3  | 1      | Papeterie > Classement
 2  | 1      | Papeterie > Écriture
 5  | 2      | Papeterie > Écriture > Crayons
 4  | 2      | Papeterie > Écriture > Stylos

Good to know:

  • Recursion is bounded to 1,000 iterations by the variable cte_max_recursion_depth (SHOW cte_max_recursion_depth, SET cte_max_recursion_depth TO 100000). Beyond that:
    ERROR 54000: Recursive query aborted after 1001 iterations. Try increasing
    @@cte_max_recursion_depth to a larger value.
  • A recursion without a stopping condition is not stopped by LIMIT: the expression is computed in full before the main query. WITH RECURSIVE r (n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM r) SELECT n FROM r LIMIT 3 returns the error above (PostgreSQL returns three rows). Write the stopping condition in the recursive term (WHERE n < 3).
  • With UNION, recursion stops by itself when no new row appears (simple cycle detection).
  • The SEARCH and CYCLE clauses are not recognized (42601).
  • WITH RECURSIVE is not allowed in a view (0A000: WITH RECURSIVE in a stored definition is not supported yet in the PostgreSQL language).

35.10 Window functions#

A window function computes, for each row, a value over a set of neighboring rows (the window), without grouping the result.

function(...) [FILTER (WHERE condition)] OVER (
    [PARTITION BY expr [, ...]]
    [ORDER BY expr [ASC | DESC] [, ...]]
    [{ROWS | RANGE} BETWEEN bound AND bound]
)
function(...) OVER window_name          -- with WINDOW name AS (...)
FunctionsPurpose
row_number(), rank(), dense_rank()number, rank with gaps, rank without gaps
ntile(n), percent_rank(), cume_dist()distribution
lag(expr [, offset [, default]]), lead(…)value of a previous or following row
first_value(expr), last_value(expr), nth_value(expr, n)value of a row of the frame
count, sum, avg, min, max, stddev, variance... with OVERaggregate over the window

Ranks#

SELECT nom, ville, solde,
       row_number() OVER (ORDER BY solde DESC) AS rang,
       rank() OVER (PARTITION BY ville ORDER BY solde DESC) AS rang_ville
FROM client ORDER BY rang;
 nom    | ville       | solde  | rang | rang_ville
--------+-------------+--------+------+------------
 David  | Constantine | 250.00 | 1    | 1
 Alice  | Oran        | 100.50 | 2    | 1
 Émile  | Alger       | 75.25  | 3    | 1
 Fatima | NULL        | 10.00  | 4    | 1
 Chloé  | Oran        | 0.00   | 5    | 2
 Bob    | Alger       | -20.00 | 6    | 2

A window named by the WINDOW clause serves several functions; a window can extend another (WINDOW w AS (PARTITION BY etat), w2 AS (w ORDER BY id)).

SELECT etat, id, rank() OVER w AS r, dense_rank() OVER w AS dr, row_number() OVER w AS rn
FROM commande WINDOW w AS (ORDER BY etat) ORDER BY id;
 etat    | id | r | dr | rn
---------+----+---+----+----
 livrée  | 1  | 2 | 2  | 2
 ouverte | 2  | 4 | 3  | 4
 livrée  | 3  | 2 | 2  | 3
 ouverte | 4  | 4 | 3  | 5
 annulée | 5  | 1 | 1  | 1

Aggregates over a window#

With ORDER BY, the default frame goes from the start of the partition to the current row: the aggregate is a running total. OVER () covers the whole result. ROWS BETWEEN … AND … sets the frame in number of rows (UNBOUNDED PRECEDING, n PRECEDING, CURRENT ROW, n FOLLOWING, UNBOUNDED FOLLOWING).

SELECT co.id, t.montant,
       sum(t.montant) OVER (ORDER BY co.date_cmd) AS cumul,
       round(100 * t.montant / sum(t.montant) OVER (), 1) AS part
FROM commande co
JOIN (SELECT commande_id, sum(total) AS montant FROM ligne_commande GROUP BY commande_id) t
  ON t.commande_id = co.id
ORDER BY co.date_cmd;
 id | montant | cumul  | part
----+---------+--------+------
 1  | 19.00   | 19.00  | 10.4
 2  | 6.90    | 25.90  | 3.8
 3  | 126.00  | 151.90 | 68.7
 4  | 31.50   | 183.40 | 17.2
SELECT id, avg(id) OVER (ORDER BY date_cmd ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS moy_glissante
FROM commande ORDER BY date_cmd;
-- 1.5, 2.0, 3.0, 4.0, 4.5 (with sixteen decimals)

SELECT id, count(*) FILTER (WHERE etat = 'livrée') OVER (ORDER BY id) AS livrees
FROM commande ORDER BY id;                                    -- 1, 1, 2, 2, 2

A window function can operate on an aggregate of a GROUP BY:

SELECT ville, count(*) AS n, rank() OVER (ORDER BY count(*) DESC) AS rang
FROM client GROUP BY ville ORDER BY rang, ville;
-- Alger 2 1, Oran 2 1, Constantine 1 3, NULL 1 3

Neighboring rows#

SELECT id, date_cmd,
       lag(date_cmd) OVER (ORDER BY date_cmd) AS precedente,
       date_cmd - lag(date_cmd) OVER (ORDER BY date_cmd) AS jours,
       lead(id, 1, 0) OVER (ORDER BY date_cmd) AS suivante
FROM commande ORDER BY date_cmd;
 id | date_cmd   | precedente | jours | suivante
----+------------+------------+-------+----------
 1  | 2026-09-01 | NULL       | NULL  | 2
 2  | 2026-09-15 | 2026-09-01 | 14    | 3
 3  | 2026-09-20 | 2026-09-15 | 5     | 4
 4  | 2026-10-02 | 2026-09-20 | 12    | 5
 5  | 2026-10-05 | 2026-10-02 | 3     | 0
SELECT code, prix,
       first_value(code) OVER w AS moins_cher,
       last_value(code) OVER (ORDER BY prix ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS plus_cher,
       nth_value(code, 2) OVER w AS deuxieme
FROM article WINDOW w AS (ORDER BY prix) ORDER BY prix;
 code  | prix  | moins_cher | plus_cher | deuxieme
-------+-------+------------+-----------+----------
 GO-01 | 0.80  | GO-01      | AG-01     | NULL
 ST-01 | 1.20  | GO-01      | AG-01     | ST-01
 TR-01 | 2.10  | GO-01      | AG-01     | ST-01
 CA-01 | 3.50  | GO-01      | AG-01     | ST-01
 CL-01 | 6.90  | GO-01      | AG-01     | ST-01
 AG-01 | 14.00 | GO-01      | AG-01     | ST-01

(last_value requires a frame extended to the end, since the default frame stops at the current row; nth_value(code, 2) is NULL as long as the frame has only one row.)

First of each group#

A window function is not allowed in WHERE (42803): filter on its result through a derived table (or with SELECT DISTINCT ON, see 35.2, for the first one only).

SELECT ville, nom, solde
FROM (SELECT ville, nom, solde,
             row_number() OVER (PARTITION BY ville ORDER BY solde DESC) AS rn
      FROM client) x
WHERE rn = 1
ORDER BY ville;
 ville       | nom    | solde
-------------+--------+--------
 Alger       | Émile  | 75.25
 Constantine | David  | 250.00
 Oran        | Alice  | 100.50
 NULL        | Fatima | 10.00

With rank() instead of row_number(), all the tied rows of each group are returned.

Limits#

FormResponse
RANGE BETWEEN n PRECEDING AND … (RANGE frame with an offset)0A000: This version of Miraj doesn't yet support 'RANGE with an offset in a window frame'
GROUPS frame, EXCLUDE clause42601
string_agg(…) OVER (…)0A000
window function in WHERE42803: Invalid use of group function

35.11 Read locks: FOR UPDATE, FOR SHARE#

In a transaction, a locking read holds the rows read until COMMIT or ROLLBACK, so they can be modified afterwards without another session changing them in the meantime.

SELECT ... FOR {UPDATE | NO KEY UPDATE | SHARE | KEY SHARE} [OF table [, ...]] [NOWAIT | SKIP LOCKED]
BEGIN;
SELECT id, stock FROM article WHERE id = 1 FOR UPDATE;      -- 1 | 500: the row is held
UPDATE article SET stock = stock - 10 WHERE id = 1;
COMMIT;
ClauseEffect
FOR UPDATE, FOR NO KEY UPDATEexclusive lock: another session can neither lock nor modify these rows
FOR SHARE, FOR KEY SHAREshared lock: other sessions can take it too (FOR SHARE), none can take FOR UPDATE or modify
OF tablein a join, locks only the rows of the named table (by its alias)
NOWAITfails immediately if a row is held by another session
SKIP LOCKEDskips the rows held by another session

Observed behavior between two sessions, session A holding FOR UPDATE on article 1:

Session BResult
SELECT … WHERE id = 1 (plain read)returns the row immediately: an ordinary read never waits
SELECT … WHERE id = 1 FOR UPDATE NOWAIT55P03 immediately: Lock wait timeout exceeded; try restarting transaction
SELECT … WHERE id = 1 FOR SHARE NOWAIT55P03 immediately
SELECT … WHERE id <= 3 ORDER BY id FOR UPDATE SKIP LOCKEDreturns rows 2 and 3
SELECT … WHERE id = 1 FOR UPDATEwaits for A's transaction to end, then returns the row; 55P03 after lock_wait_timeout seconds
UPDATE article … WHERE id = 1waits likewise; 55P03 after lock_wait_timeout

The maximum wait is set per session: SET lock_wait_timeout = 2 (in seconds). The SQLSTATE 55P03 is the PostgreSQL one for NOWAIT; here it is also used for an expired wait.

SKIP LOCKED lets several sessions share a work queue:

BEGIN;
SELECT id FROM commande WHERE etat = 'ouverte' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;
-- process the returned order, then:
UPDATE commande SET etat = 'livrée' WHERE id = 2;
COMMIT;

Outside a transaction block, the clause is accepted but the lock is released at the end of the statement: it is only useful between BEGIN and COMMIT. The concurrency model, commit conflicts and retries are described in chapter 37. Transactions and session.


35.12 Reading a view#

A view (see 33. Data definition language (DDL)) is read like a table, in all the forms of this chapter: joins, grouping, windows, subqueries.

CREATE VIEW v_ca AS
SELECT c.id, c.nom, sum(l.total) AS ca
FROM client c
JOIN commande co ON co.client_id = c.id
JOIN ligne_commande l ON l.commande_id = co.id
GROUP BY c.id, c.nom;

SELECT nom, ca, rank() OVER (ORDER BY ca DESC) AS rang FROM v_ca ORDER BY rang;
 nom   | ca     | rang
-------+--------+------
 Bob   | 126.00 | 1
 Chloé | 31.50  | 2
 Alice | 25.90  | 3

The query of a view allows WITH (non-recursive), ORDER BY and LIMIT. It does not allow FULL JOIN, LATERAL or WITH RECURSIVE (0A000: … in a stored definition is not supported yet in the PostgreSQL language), which remain usable in a direct query.


35.13 MIRAJ extensions in a SELECT#

These forms are not PostgreSQL; they come from the MIRAJ language and remain accepted. A script that must also run on a PostgreSQL server must not use them.

FormEffect
QUALIFY conditionfilters on the result of a window function, without a derived table
GROUP BY a, b WITH ROLLUPequivalent of GROUP BY ROLLUP (a, b)
select-list alias in HAVINGHAVING ecart > 0 where ecart is an alias (PostgreSQL refuses it)
x COLLATE utf8mb4_general_cicomparison and sorting without case or accents (35.4)
MATCH (columns) AGAINST ('words')full-text search, on an index created by CREATE FULLTEXT INDEX (see 8.22); words are compared according to the column's collation, so case is respected
SELECT ville, nom, solde FROM client
QUALIFY row_number() OVER (PARTITION BY ville ORDER BY solde DESC) = 1
ORDER BY ville;
-- same result as "First of each group" (35.10)

CREATE FULLTEXT INDEX ft_lib ON article (libelle);
SELECT code, libelle FROM article WHERE MATCH (libelle) AGAINST ('pages');    -- CA-01 | Cahier 96 pages
SELECT code, libelle FROM article WHERE MATCH (libelle) AGAINST ('cahier');   -- no rows: "Cahier"

35.14 EXPLAIN#

EXPLAIN displays the execution plan of a query without running it. The plan is that of MIRAJ, in its own layout: a table with one row per table read, not PostgreSQL's tree of nodes. Tools that parse PostgreSQL's EXPLAIN output will not read it.

EXPLAIN SELECT c.nom, co.id FROM client c JOIN commande co ON co.client_id = c.id WHERE c.ville = 'Oran';
 id | select_type | table | type | possible_keys | key  | key_len | ref  | rows | Extra
----+-------------+-------+------+---------------+------+---------+------+------+-------------------------------
 1  | SIMPLE      | c     | ALL  | PRIMARY,email | NULL | NULL    | NULL | 6    | Using where
 1  | SIMPLE      | co    | ALL  | PRIMARY       | NULL | NULL    | NULL | 5    | Using join buffer (hash join)
EXPLAIN SELECT * FROM client WHERE id = 1;
 id | select_type | table  | type   | possible_keys | key     | key_len | ref   | rows | Extra
----+-------------+--------+--------+---------------+---------+---------+-------+------+-------
 1  | SIMPLE      | client | eq_ref | PRIMARY,email | PRIMARY | NULL    | const | 1    |

type indicates the access method (ALL: read of the whole table; eq_ref: one row per unique key), key the index chosen, rows the estimated number of rows read. How to read a plan in detail is described in sections 8.20 and 8.21 of the general manual.

FormResult
EXPLAIN querytable above
EXPLAIN (FORMAT JSON) querythe same plan as a JSON document (one column EXPLAIN)
EXPLAIN ANALYZE queryruns the query and returns the tree of operations with actual times and rows
EXPLAIN (ANALYZE, VERBOSE, COSTS off, BUFFERS) queryoptions accepted; same output as EXPLAIN ANALYZE
EXPLAIN (FORMAT XML), EXPLAIN (FORMAT YAML)0A000: EXPLAIN (FORMAT XML | YAML) is not supported yet in the PostgreSQL language
EXPLAIN ANALYZE SELECT * FROM client WHERE ville = 'Oran';
-> Project  (actual time=0.010..0.011 rows=2 loops=1)
    -> Table scan on client with pushed-down filter  (rows=6)  (actual time=0.009..0.009 rows=2 loops=1)

The tag returned is EXPLAIN. EXPLAIN also applies to UPDATE and DELETE (see 34.9).


35.15 Unsupported features and differences#

Rejected forms#

PostgreSQL formMIRAJ responseInstead
LIMIT of an expression (LIMIT 1 + 1), LIMIT -142601written integer or parameter
LIMIT 10, 5 (MIRAJ language form)42601: LIMIT offset, count is MIRAJ syntax, not accepted in the PostgreSQL languageLIMIT 5 OFFSET 10
a && b, a || b as logical operators42601 for &&; || concatenatesAND, OR
derived table without an alias: FROM (SELECT …)42601: syntax error at end of inputgive an alias
join alias: JOIN … USING (id) AS j42601
NATURAL CROSS JOIN42601
LATERAL with RIGHT or FULL JOIN, USING, NATURAL0A000: … 'LATERAL with RIGHT or FULL JOIN, USING or NATURAL'
generate_series whose bounds reference another table42703
unnest of a column, ARRAY(SELECT …), array-typed columns0A000array_agg, string_agg, json_agg
TABLE client42601SELECT * FROM client
SELECT … INTO new_table42000: Undeclared variable: copieCREATE TABLE … AS SELECT …
TABLESAMPLE42601
SIMILAR TO42601~
ORDER BY … USING operator42601ASC, DESC
ORDER BY 3 NULLS LAST when the position designates a column of *0A000: NULLS FIRST | LAST on this column position is not supported yet in the PostgreSQL languagename the column
(a, b) = (SELECT …, …)42601: Operand should contain 1 column(s)(a, b) IN (SELECT …, …)
WITH … SEARCH, WITH … CYCLE42601path column computed in the recursion
writing WITH (WITH x AS (INSERT … RETURNING …)) anywhere other than at the head of the statement0A000write it at the head, or two statements in a transaction
WITH RECURSIVE, FULL JOIN, LATERAL in a view0A000direct query
RANGE frame with offset, GROUPS, EXCLUDE; string_agg … OVER0A000, 42601 (35.10)ROWS frame
EXPLAIN (FORMAT XML | YAML)0A000FORMAT JSON

Behavior differences#

SubjectMIRAJPostgreSQL
quoted identifierscase-insensitivecase-sensitive
name of a computed column without an aliastext as written (count(*), solde * 2)count, ?column?
select-list alias in HAVINGacceptedrefused
SELECT DISTINCT a … ORDER BY b (key absent from the list)acceptedrefused
WITH RECURSIVE without a stopping condition, with LIMITerror at 1,000 iterations (cte_max_recursion_depth)stops at LIMIT
generate_seriesat most 10,000,000 values, error 54000 beyondno limit
FOR UPDATE outside a transaction, or on an aggregate queryacceptedno useful effect, refused with an aggregate
expired lock wait55P03 after lock_wait_timeoutunlimited wait by default
EXPLAINMIRAJ plan and layouttree of PostgreSQL nodes
avg of integers or decimalsnumeric with sixteen decimalsvariable scale depending on the value

The condensed list of these points is kept in chapter 29 (section 29.2).

SQLSTATEs encountered in this chapter#

SQLSTATEMeaningExample
0A000not supportedFULL JOIN in a view
21000scalar subquery with several rowsSELECT (SELECT nom FROM client)
22023invalid argumentzero step in generate_series, percentile outside 0..1
22P02invalid representationnon-numeric text compared with a number; text that cannot be read in the type of a union
42601syntaxunrecognized forms above; different numbers of columns in a UNION
42702ambiguous columnWHERE id = 1 in a join of two tables that both have id
42703unknown columnselect-list alias referenced in WHERE; ORDER BY of a position outside the list
42803groupingcolumn outside GROUP BY, window function in WHERE
42P01unknown table
42P10inconsistent listtoo many column aliases; DISTINCT ON that does not start the ORDER BY
54000limit reachedrecursion beyond cte_max_recursion_depth, generate_series of more than 10,000,000 values
55P03lock not obtainedNOWAIT, expired wait