PostgreSQL bietet zwei verschiedene JSON-Typen: json und jsonb. Sie sehen sich beim Insert ähnlich, verhalten sich beim Lesen, Indexieren und Manipulieren aber sehr unterschiedlich. Dieser Artikel zeigt die Unterschiede in der Praxis und erklärt, warum jsonb in fast allen modernen Setups die richtige Wahl ist.
Auf einen Blick
| Aspekt | json | jsonb |
|---|---|---|
| Speicherung | Text-Repräsentation 1:1 | binäres, geparstes Format |
| Whitespace, Schlüssel-Reihenfolge | bleibt erhalten | normalisiert (verloren) |
| Doppelte Schlüssel | bleiben erhalten | nur der letzte zählt |
| Insert-Performance | minimal schneller (kein Parsing) | minimal langsamer (Parsing) |
| Read/Operator-Performance | langsam (jede Operation parst neu) | schnell (binär bereits geparst) |
| Indexe (GIN) | nicht möglich | voll unterstützt |
@> Containment | nein | ja |
| Operator-Reichtum | gering | groß |
| Typische Größe | etwas größer | etwas kompakter |
Faustregel: Wenn du JSON-Werte einmal speicherst und nie wieder strukturiert anfasst — json reicht (selten der Fall). Sobald du JSON liest, filterst oder änderst — jsonb. Das trifft auf >95 % der realen Use-Cases zu.
Tabellen anlegen
CREATE TABLE products_a (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku text NOT NULL,
data json
);
CREATE TABLE products_b (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku text NOT NULL,
data jsonb
);Inserts sehen identisch aus:
INSERT INTO products_a (sku, data) VALUES
('A1', '{"color": "red", "color": "blue", "tags": ["new", "sale"]}');
INSERT INTO products_b (sku, data) VALUES
('A1', '{"color": "red", "color": "blue", "tags": ["new", "sale"]}');Beim Lesen aber:
myapp=> SELECT data FROM products_a;
data
----------------------------------------------------
{"color": "red", "color": "blue", "tags": ["new", "sale"]}
-- json speichert wörtlich, beide "color"-Schlüssel bleiben
myapp=> SELECT data FROM products_b;
data
------------------------------
{"color": "blue", "tags": ["new", "sale"]}
-- jsonb behält nur den LETZTEN doppelten Schlüssel
-- und kann Reihenfolge ändernjsonb parst beim Insert und speichert nur den semantischen Inhalt — Whitespace, Reihenfolge der Schlüssel und Duplikate werden verworfen. Das ist meistens das, was man eigentlich will.
Operatoren auf jsonb
jsonb unterstützt eine reiche Palette an Operatoren:
| Operator | Beispiel | Wirkung |
|---|---|---|
-> | data->'color' | gibt Wert als jsonb zurück |
->> | data->>'color' | gibt Wert als text zurück |
#> | data#>'{a,b,c}' | tiefer Zugriff über Pfad, als jsonb |
#>> | data#>>'{a,b,c}' | tiefer Zugriff, als text |
@> | data @> '{"color":"red"}' | enthält der Wert das rechte Objekt? |
<@ | '{"color":"red"}' <@ data | umgekehrt |
? | data ? 'color' | existiert dieser Schlüssel? |
?| | data ?| array['a','b'] | existiert mindestens einer dieser Schlüssel? |
?& | data ?& array['a','b'] | existieren alle diese Schlüssel? |
|| | data || '{"new":1}'::jsonb | merge zweier jsonb-Werte |
- | data - 'color' | Schlüssel entfernen |
#- | data #- '{a,b}' | Pfad entfernen |
json (ohne b) hat nur die einfachen Lese-Operatoren ->, ->>, #>, #>>. Alle anderen — Containment, Schlüssel-Existenz, Manipulation — gibt es nur für jsonb. Das ist der größte praktische Unterschied.
Erstes Beispiel: Lesen mit Operatoren
myapp=> SELECT
sku,
data->>'color' AS color, -- als text
data->'tags' AS tags_jsonb, -- als jsonb
data->'tags'->>0 AS first_tag -- erstes Array-Element als text
FROM products_b;
sku | color | tags_jsonb | first_tag
-----+---------+-------------------+-----------
A1 | blue | ["new", "sale"] | new-> liefert einen jsonb-Wert (kann also weiter mit Operatoren bearbeitet werden), ->> liefert direkt einen text-Wert (perfekt für WHERE-Vergleiche).
-- Produkte einer bestimmten Farbe
SELECT sku FROM products_b WHERE data->>'color' = 'red';
-- Produkte mit Tag 'sale'
SELECT sku FROM products_b WHERE data->'tags' ? 'sale';
-- Produkte mit bestimmtem Teilobjekt
SELECT sku FROM products_b
WHERE data @> '{"color": "red"}'::jsonb;Das @> ist besonders praktisch: man gibt ein Teil-JSON an und Postgres findet alle Zeilen, die dieses Teil enthalten. Mit GIN-Index unter Millisekunden auf großen Tabellen.
Indexe — der entscheidende Unterschied
json kann nicht über GIN indexiert werden. jsonb schon — und damit werden Containment- und Existenz-Queries auch auf Millionen-Zeilen-Tabellen schnell:
CREATE INDEX products_data_idx ON products_b USING gin (data);
-- Diese Queries nutzen den Index:
SELECT * FROM products_b WHERE data @> '{"color": "red"}';
SELECT * FROM products_b WHERE data ? 'discount';
SELECT * FROM products_b WHERE data ?| array['sale', 'new'];Mehr zu GIN-Indexen auf JSONB im eigenen Artikel: JSONB-Indexe.
Wann ist json (ohne b) trotzdem die richtige Wahl?
Sehr selten. Konkrete Gründe:
- Audit/Logging: Du willst die Original-JSON-Repräsentation byte-für-byte behalten — z. B. weil ein externes System sie unterschrieben hat und die Signatur sonst nicht mehr stimmt.
- API-Pass-Through: Du speicherst JSON nur, um es später unverändert wieder zurückzuliefern. Dann sparst du dir das Parsing beim Insert.
- Rohdaten-Storage mit doppelten Schlüsseln, die du explizit behalten musst.
In allen anderen Fällen: jsonb.
Konvertierung zwischen den Typen
-- json zu jsonb (parst und normalisiert)
SELECT '{"a":1, "a":2}'::json::jsonb;
-- jsonb
-- ----------------
-- {"a": 2}
-- jsonb zu text (für JSON-API-Output)
SELECT (data::text) FROM products_b;
-- jsonb pretty-printed
SELECT jsonb_pretty(data) FROM products_b;jsonb_pretty() ist nützlich für Debugging — formatiert mit Einrückung und Zeilenumbrüchen.
Häufige Stolperfallen
jsonb verliert Reihenfolge und Duplikate.
'{"a": 1, "b": 2}'::jsonb und '{"b": 2, "a": 1}'::jsonb sind identisch — Postgres normalisiert die Reihenfolge intern. Wer auf bestimmte Schlüssel-Reihenfolge angewiesen ist (z. B. für Hashing oder Signatur), darf das nicht mit jsonb machen. json (ohne b) bewahrt die ursprüngliche Form.
-> vs. ->> — der Unterschied ist wichtig.
data->'color' liefert ein jsonb (also "red" mit Anführungszeichen, weil’s ein JSON-String ist). data->>'color' liefert ein text (also red ohne Anführungszeichen). In WHERE-Klauseln willst du fast immer ->>. Wer data->'color' = 'red' schreibt, vergleicht jsonb mit text und bekommt einen Type-Fehler oder unerwartetes Verhalten.
Containment funktioniert nur für gleiche Strukturen.
data @> '{"color":"red"}' matcht nur, wenn data ein Objekt ist mit Schlüssel color und Wert red. Wer den Wert in einem Array sucht (data->'colors' @> '["red"]'), muss die Pfad-Notation oder Subqueries nutzen. Containment ist strukturell, nicht „irgendwo enthalten”.
Storage-Größe ist nicht 1:1 vorhersehbar.
jsonb komprimiert Werte intern und kann je nach Inhalt etwas größer oder kleiner sein als json. Bei sehr kleinen Werten oft kompakter (Float-Werte, Booleans), bei sehr großen Werten plus Header etwas größer. In der Praxis: ähnlich. Speicher ist selten der Engpass.
Indexe brauchen passende Operatoren.
Ein Standard-GIN-Index unterstützt alle gängigen Operatoren (@>, ?, ?|, ?&). Mit jsonb_path_ops als Index-Klasse bekommst du einen kompakteren, schnelleren Index — aber dann nur für @>. Faustregel: erst jsonb_path_ops versuchen; nur wenn du Existenz-Queries (?) oft brauchst, den Default-GIN.
JSON-Pfad-Queries sind ein eigenes Sub-Thema.
Postgres hat seit Version 12 einen eigenen JSON-Path-Standard (jsonb_path_query etc.) — ähnlich XPath. Mächtig für komplexe Selektionen, aber eine eigene Sprache zum Lernen. Eigener Artikel: JSONB Pfad-Queries.
Weiterführende Ressourcen
Externe Quellen
- JSON Types – PostgreSQL Documentation
- JSON Functions and Operators
- GIN Indexes – PostgreSQL Documentation