JSON a XML v PostgreSQL

PostgreSQL ukládá JSON ve dvou typech: json drží přesný text, jsonb rozloženou strukturu, nad kterou fungují operátory obsahu, jsonpath a GIN indexy. Pro aplikační data je výchozí volbou jsonb. XML ukládá typ xml s dotazy XPath 1.0 a funkcí XMLTABLE.

Autor: Aktualizováno

json, nebo jsonb?

Oba typy přijmou jen platný JSON text. Rozdíl je v tom, co si databáze uloží. Typ json drží vstup znak po znaku. Typ jsonb ho při vložení rozloží, takže zahodí bílé znaky, seřadí klíče podle vlastního pořadí a z duplicitních klíčů nechá poslední hodnotu:

PostgreSQL 18 — stejný vstup ve dvou typech
SELECT '{"b": 1,  "a": 2, "a": 3}'::json  AS json,
       '{"b": 1,  "a": 2, "a": 3}'::jsonb AS jsonb;

           json            |      jsonb
---------------------------+------------------
 {"b": 1,  "a": 2, "a": 3} | {"a": 3, "b": 1}
Vlastnostjsonjsonb
Uloženípřesná kopie vstupního texturozložená binární reprezentace
Bílé znaky a pořadí klíčůzachovánezachová
Duplicitní klíčezachová všechny (funkce berou poslední)ponechá jen poslední
Vloženírychlejší (jen kontrola syntaxe)pomalejší (převod)
Dotazypři každém dotazu znovu parsujebez opakovaného parsování
Operátory @>, ?, @?, @@ a GIN indexneano

json má smysl tam, kde musíš uchovat přesný přijatý text, například pro audit nebo kontrolu podpisu. Pro data, nad kterými se dotazuješ, použij jsonb. Čísla ukládá jsonb jako numeric. Zachová tedy počet desetinných míst, ale zápis normalizuje:

Čísla v jsonb
SELECT '{"cena": 199.90, "velke": 1e2}'::jsonb;
-- {"cena": 199.90, "velke": 100}

Escapovaný zápis "ž" a přímé "ž" jsou v JSON stejný řetězec a jsonb je uloží stejně. Escapování diakritiky na straně aplikace tak nemá vliv na obsah sloupce, jen na velikost přenášeného textu. Viz řetězce v JSON.

Ukázková tabulka

Další příklady pracují s touto tabulkou. Kontrola CHECK zajistí, že sloupec obsahuje objekt, ne libovolnou JSON hodnotu. Povinné klíče a typy jejich hodnot ale kontroluje až aplikace nebo JSON Schema.

Tabulka objednávek
CREATE TABLE objednavka (
  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  data jsonb NOT NULL,
  CONSTRAINT data_je_objekt CHECK (jsonb_typeof(data) = 'object')
);

INSERT INTO objednavka (data) VALUES
 ('{"zakaznik":{"jmeno":"Jana","mesto":"Brno"},"stav":"nova","stitky":["expres","firma"],
    "polozky":[{"kod":"A1","ks":2,"cena":199.9},{"kod":"B7","ks":1,"cena":49}]}'),
 ('{"zakaznik":{"jmeno":"Petr","mesto":"Praha"},"stav":"odeslana","stitky":["firma"],
    "polozky":[{"kod":"A1","ks":1,"cena":199.9}]}'),
 ('{"zakaznik":{"jmeno":"Eva","mesto":"Brno"},"stav":"nova","polozky":[]}');

Čtení hodnot: operátory

OperátorVracíVýznam
->jsonbpoložka objektu podle klíče nebo prvek pole podle indexu (od 0)
->>texttotéž jako text; JSON null dá SQL NULL
#> / #>>jsonb / textpoložka podle cesty, např. '{polozky,0,kod}'
@>booleanlevá hodnota obsahuje pravou (podstrukturu)
?booleanklíč nebo řetězcový prvek pole existuje na nejvyšší úrovni
?| / ?&booleanexistuje některý / všechny z klíčů
@?booleanjsonpath vrátí aspoň jednu položku
@@booleanvýsledek predikátu jsonpath
|| / - / #-jsonbsloučení na nejvyšší úrovni / odstranění klíče / odstranění podle cesty

Rozhodující je rozdíl mezi -> a ->>. První vrací jsonb a hodí se pro další zanořování. Druhý vrací text pro porovnání, výstup a přetypování, například (data->>'cena')::numeric.

Extrakce hodnot
SELECT id,
       data->'zakaznik'          AS objekt,     -- jsonb
       data->'zakaznik'->>'jmeno' AS jmeno,      -- text
       data#>>'{polozky,0,kod}'  AS prvni_kod   -- text podle cesty
FROM objednavka ORDER BY id;

 id |               objekt                | jmeno | prvni_kod
----+-------------------------------------+-------+-----------
  1 | {"jmeno": "Jana", "mesto": "Brno"}  | Jana  | A1
  2 | {"jmeno": "Petr", "mesto": "Praha"} | Petr  | A1
  3 | {"jmeno": "Eva", "mesto": "Brno"}   | Eva   |
Filtrování podle obsahu; v komentáři výsledná id
-- obsahuje tuto podstrukturu?                     → 1, 3
SELECT id FROM objednavka WHERE data @> '{"stav":"nova","zakaznik":{"mesto":"Brno"}}';

-- existuje klíč na nejvyšší úrovni?                  → 1, 2
SELECT id FROM objednavka WHERE data ? 'stitky';

-- obsahuje pole řetězec?                             → 1
SELECT id FROM objednavka WHERE data->'stitky' ? 'expres';

-- vrátí jsonpath aspoň jednu položku?                → 1, 2
SELECT id FROM objednavka WHERE data @? '$.polozky[*] ? (@.cena > 100)';

-- je predikát jsonpath pravdivý?                     → 3
SELECT id FROM objednavka WHERE data @@ '$.polozky.size() == 0';

Operátor ? hledá jen na nejvyšší úrovni hodnoty, na kterou ho použiješ. Proto se prvek pole hledá výrazem data->'stitky' ? 'expres'. Výraz @> porovnává strukturu: '{"zakaznik":{"mesto":"Brno"}}' odpovídá každé objednávce, jejíž zákazník má toto město, bez ohledu na další klíče.

Jazyk cest jsonpath v PostgreSQL vychází ze standardu SQL/JSON. Není to JSONPath podle RFC 9535, i když začíná stejným znakem $. Filtr se zapisuje ? (@.cena > 100), ne [?@.cena > 100].

JSON_TABLE: pole jako řádky

Od PostgreSQL 17 převede JSON_TABLE pole objektů na řádky s typovanými sloupci. Ve starších verzích stejnou práci udělá jsonb_array_elements v LATERAL spojení.

Položky objednávek jako tabulka
SELECT o.id, p.kod, p.ks, p.cena
FROM objednavka o,
     JSON_TABLE(o.data, '$.polozky[*]' COLUMNS (
       kod  text    PATH '$.kod',
       ks   integer PATH '$.ks',
       cena numeric PATH '$.cena')) AS p
ORDER BY o.id, p.kod;

 id | kod | ks | cena
----+-----+----+-------
  1 | A1  |  2 | 199.9
  1 | B7  |  1 |    49
  2 | A1  |  1 | 199.9

Objednávka 3 má prázdné pole položek, a proto ve výsledku chybí. Pokud ji potřebuješ, použij LEFT JOIN JSON_TABLE(…) ON true.

Generování JSON v SQL dotazu

Server může vrátit hotovou strukturu místo řádků, které by aplikace teprve skládala. jsonb_build_object vytvoří objekt z dvojic klíč–hodnota a jsonb_agg spojí řádky skupiny do pole:

Objednávky seskupené podle města
SELECT data->'zakaznik'->>'mesto' AS mesto,
       jsonb_agg(jsonb_build_object('id', id, 'stav', data->>'stav') ORDER BY id) AS objednavky
FROM objednavka
GROUP BY 1 ORDER BY 1;

 mesto |                       objednavky
-------+--------------------------------------------------------
 Brno  | [{"id": 1, "stav": "nova"}, {"id": 3, "stav": "nova"}]
 Praha | [{"id": 2, "stav": "odeslana"}]

Funkce json_build_object a json_agg dělají totéž, ale vrací json, takže zachovají pořadí klíčů tak, jak je zapíšeš. Od verze 16 existují i standardní konstruktory JSON_OBJECT a JSON_ARRAYAGG.

Agregace nad prázdnou množinou vrací SQL NULL, ne prázdné pole:

Prázdný výsledek
SELECT jsonb_agg(x) FROM (SELECT 1 AS a WHERE false) x;                  -- NULL
SELECT coalesce(jsonb_agg(x), '[]') FROM (SELECT 1 AS a WHERE false) x;   -- []

Úpravy dokumentu

Změna, přidání a odebrání klíče
UPDATE objednavka SET data = jsonb_set(data, '{stav}', '"zaplacena"') WHERE id = 1;
UPDATE objednavka SET data = data || '{"poznamka":"volat předem"}'  WHERE id = 2;
UPDATE objednavka SET data = data - 'poznamka'                      WHERE id = 2;

Každý UPDATE zapíše novou verzi celého řádku, i když změníš jediný klíč. U velkých dokumentů, které se často mění po částech, proto zvaž, jestli ty části nepatří do samostatných sloupců. Operátor || slučuje jen nejvyšší úroveň, vnořený objekt nahradí celý.

Indexy: GIN a B-strom nad výrazem

GIN index nad jsonb indexuje klíče a hodnoty dokumentu. Zrychlí dotazy na obsah (@>), existenci klíče (?) a jsonpath (@?, @@). Není to fulltextový index: nehledá slova uvnitř řetězců. Fulltext dělá typ tsvector, například přes to_tsvector('simple', data->>'popis').

Tři druhy indexů
-- univerzální: @>, ?, ?|, ?&, @?, @@
CREATE INDEX udalost_data_gin   ON udalost USING GIN (data);

-- menší, ale jen @>, @?, @@ (bez operátorů ?)
CREATE INDEX udalost_data_path  ON udalost USING GIN (data jsonb_path_ops);

-- jeden klíč porovnávaný na rovnost nebo rozsah: běžný B-strom nad výrazem
CREATE INDEX udalost_uzivatel   ON udalost ((data->>'uzivatel'));

Index se použije, jen když dotaz obsahuje stejný operátor nebo stejný výraz jako index. To ukazují plány z tabulky s 200 000 řádky:

Výběr z plánů EXPLAIN, PostgreSQL 18.4
EXPLAIN (COSTS OFF) SELECT count(*) FROM udalost WHERE data->>'uzivatel' = 'u42';
   ->  Bitmap Index Scan on udalost_uzivatel
         Index Cond: ((data ->> 'uzivatel'::text) = 'u42'::text)

EXPLAIN (COSTS OFF) SELECT count(*) FROM udalost WHERE data->'uzivatel' = '"u42"';
   ->  Parallel Seq Scan on udalost
         Filter: ((data -> 'uzivatel'::text) = '"u42"'::jsonb)

-- jen s indexem jsonb_path_ops:
EXPLAIN (COSTS OFF) SELECT count(*) FROM udalost WHERE data ? 'reklamace';
   ->  Seq Scan on udalost
         Filter: (data ? 'reklamace'::text)

Výraz data->'uzivatel' = '"u42"' vrací stejné řádky jako data->>'uzivatel' = 'u42', ale index nad (data->>'uzivatel') nepoužije. Na této tabulce měl GIN index s jsonb_path_ops 1,6 MB, výchozí jsonb_ops 2,3 MB a B-strom nad jedním klíčem 1,4 MB. Velikosti závisí na datech; výběr ověř pomocí EXPLAIN ANALYZE na svých dotazech.

Chybějící klíč, JSON null a SQL NULL

->> vrací SQL NULL pro chybějící klíč i pro hodnotu null. Pokud ten rozdíl potřebuješ, ptej se na klíč zvlášť:

Tři výsledky t, každý z jiného důvodu
SELECT '{"a":1}'::jsonb->>'b' IS NULL     AS chybi,          -- t
       '{"b":null}'::jsonb->>'b' IS NULL  AS null_hodnota,   -- t
       '{"b":null}'::jsonb ? 'b'          AS klic_existuje;  -- t

Stejný rozdíl musíš ošetřit i v aplikaci, viz chybějící hodnoty v Go. data->'b' vrací pro {"b":null} hodnotu 'null'::jsonb, která není SQL NULL.

Dotazy z aplikace: placeholder ?

Rozhraní s otazníkovými placeholdery (PHP PDO, JDBC) považují operátory ?, ?| a ?& za parametry. PDO od PHP 7.4 i ovladač pgJDBC proto přijímají zdvojený ??, který se do databáze odešle jako jeden otazník:

PHP 8.4, pdo_pgsql
// Chybně: PDO považuje ? za placeholder
$st = $pdo->prepare("SELECT id FROM objednavka WHERE data ? 'stitky' AND data->>'stav' = ?");
// PDOException: syntax error at or near "$1"

// Správně: ?? se odešle jako operátor ?
$st = $pdo->prepare("SELECT id FROM objednavka WHERE data ?? 'stitky' AND data->>'stav' = ?");
$st->execute(['odeslana']);

// JSON parametr vždy přes json_encode, nikdy skládáním řetězce
$st = $pdo->prepare("SELECT id FROM objednavka WHERE data @> ?::jsonb");
$st->execute([json_encode(['zakaznik' => ['mesto' => 'Praha']], JSON_THROW_ON_ERROR)]);

Operátor @> otazník neobsahuje a GIN index použije také, takže je často jednodušší alternativou. Laravel where('data->stav', …) sestaví stejné dotazy za tebe, viz JSON sloupce v Laravelu.

XML v PostgreSQL

Typ xml při vložení zkontroluje, že jde o well-formed obsah. Nevaliduje proti DTD ani XSD. Ve výchozím nastavení (xmloption = content) přijme i fragment s více kořeny, takže pro celé dokumenty kontroluj IS DOCUMENT. Typ je dostupný jen v sestavení s libxml2, což běžné balíčky splňují.

Kontrola well-formed
SELECT xml '<a>1</a><b/>' IS DOCUMENT;        -- f: obsah (CONTENT), ne dokument
SELECT xml_is_well_formed_document('<a><b></a>');  -- f
SELECT '<a><b></a>'::xml;
-- ERROR:  invalid XML content
-- DETAIL:  line 1: Opening and ending tag mismatch: b line 1 and a

Funkce xpath() používá XPath 1.0. Elementy ve výchozím jmenném prostoru proto najde jen přes prefix, který předáš třetím argumentem. Podrobně to popisuje stránka XPath nic nevrací.

Atom feed bez prefixu a s prefixem
INSERT INTO feed VALUES (1, '<feed xmlns="http://www.w3.org/2005/Atom">
  <entry><title>První</title><updated>2026-10-01T08:00:00Z</updated></entry>
  <entry><title>Druhý</title><updated>2026-10-03T08:00:00Z</updated></entry>
</feed>');

SELECT xpath('//entry/title/text()', doc) FROM feed;            -- {}
SELECT xpath('//a:entry/a:title/text()', doc,
             ARRAY[ARRAY['a', 'http://www.w3.org/2005/Atom']])
FROM feed;                                                       -- {První,Druhý}

Pro převod opakujících se elementů na řádky slouží XMLTABLE (od verze 10):

Položky feedu jako tabulka
SELECT x.titulek, x.zmeneno
FROM feed,
     XMLTABLE(XMLNAMESPACES('http://www.w3.org/2005/Atom' AS a),
              '/a:feed/a:entry' PASSING doc
              COLUMNS titulek text        PATH 'a:title',
                      zmeneno timestamptz PATH 'a:updated') AS x;

 titulek |        zmeneno
---------+------------------------
 První   | 2026-10-01 11:00:00+03
 Druhý   | 2026-10-03 11:00:00+03

Funkce xmlelement a xmlattributes vytvoří XML a samy escapují text:

Generování XML
SELECT xmlelement(name objednavka, xmlattributes(42 AS id),
                  xmlelement(name zakaznik, 'Jana & syn'));
-- <objednavka id="42"><zakaznik>Jana &amp; syn</zakaznik></objednavka>

Typ xml nemá obdobu GIN indexu, jaký má jsonb, a neumí ani operátor rovnosti. Hodnoty, podle kterých často filtruješ, vytáhni do běžného sloupce nebo do indexu nad výrazem, například ((xpath('/a/@id', doc))[1]::text).

Časté chyby

  • Typ json pro data, nad kterými se dotazuje. Nefungují operátory @>, ? ani GIN index a každý dotaz text znovu parsuje.
  • Index, který dotaz nepoužije. GIN s jsonb_path_ops nezrychlí ?; index nad (data->>'klic') nezrychlí @> ani data->'klic'.
  • Vše do jednoho sloupce. Cizí klíče, unikátnost a povinné hodnoty patří do běžných sloupců s omezeními. JSON se hodí pro proměnlivou nebo méně dotazovanou část dat.
  • Skládání JSON v SQL řetězcem. Hodnotu z aplikace předávej jako parametr a přetypuj ?::jsonb, nebo ji vytvoř přes jsonb_build_object.
  • Záměna jsonpath a JSONPath. Syntaxe filtrů se liší, dotaz z JSONPath testeru nelze vložit beze změny.

Verze

Příklady jsou ověřené na PostgreSQL 18.4, aktuální stabilní řadě k 4. 10. 2026 (verze 19 je ve fázi beta). Ostatní příklady kromě JSON_TABLE fungují ve všech podporovaných verzích (14 až 18).

FunkceOd verze
json9.2
jsonb, GIN indexy jsonb_ops a jsonb_path_ops9.4
typ jsonpath, operátory @? a @@12
IS JSON, JSON_OBJECT, JSON_ARRAY, JSON_ARRAYAGG, JSON_OBJECTAGG16
JSON_TABLE, JSON_EXISTS, JSON_QUERY, JSON_VALUE17
XMLTABLE10

Zdroje: JSON Types, JSON Functions and Operators, XML Type a XML Functions.