Manchmal müssen Binär-Daten direkt in die Datenbank: Hash-Werte, kleine Bilder, kryptographische Schlüssel, PDFs. PostgreSQL bietet zwei Mechanismen — bytea als normalen Spalten-Typ und Large Objects mit eigener API. Bevor wir die Mechanik klären: in den meisten modernen Setups ist die richtige Antwort, die Binär-Daten gar nicht in die DB zu legen, sondern in Object Storage (S3, GCS, …) und nur die URL zu speichern.
bytea — Binär-Daten als Spaltenwert
CREATE TABLE attachments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
filename text NOT NULL,
content_type text NOT NULL,
data bytea NOT NULL,
size_bytes integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);bytea ist Postgres’ Standard-Typ für Binär-Werte. Größenlimit: theoretisch 1 GB pro Wert, praktisch sollte man weit darunter bleiben.
-- Hex-Notation:
INSERT INTO attachments (filename, content_type, data, size_bytes)
VALUES ('logo.bin', 'application/octet-stream', '\x89504e470d0a1a0a', 8);
-- Aus einer Anwendung würde man normalerweise einen
-- Buffer/Bytes-Wert als Parameter übergeben — keine Hex-Strings.In Anwendungen hängt’s vom Treiber ab:
import { readFile } from 'fs/promises';
const buffer = await readFile('logo.png');
await client.query(
`INSERT INTO attachments (filename, content_type, data, size_bytes)
VALUES ($1, $2, $3, $4)`,
['logo.png', 'image/png', buffer, buffer.length]
);data, _ := os.ReadFile("logo.png")
_, err := conn.Exec(ctx,
`INSERT INTO attachments (filename, content_type, data, size_bytes)
VALUES ($1, $2, $3, $4)`,
"logo.png", "image/png", data, len(data),
)Lesen ist symmetrisch — der Treiber liefert ein Buffer/[]byte zurück.
TOAST — Postgres’ transparenter Big-Value-Storage
Werte, die größer als ~2 KB sind, lagert Postgres automatisch in eine separate TOAST-Tabelle aus (The Oversized-Attribute Storage Technique). Das ist transparent für die Anwendung — du fragst eine Zeile mit bytea ab und bekommst alles zurück, egal wie groß.
Konsequenz: kleine Inline-Werte sind schnell, große Werte sind langsamer (zusätzliche Indirektion). Komprimiert wird auch automatisch (LZ4 ab PG 14, vorher pglz).
SELECT attname, attstorage
FROM pg_attribute
WHERE attrelid = 'attachments'::regclass
AND attnum > 0;Werte für attstorage: p = plain (nie TOAST), e = external (TOAST ohne Kompression), m = main (Kompression bevorzugt), x = extended (Kompression + TOAST). bytea und text sind per Default x.
Large Objects — die Stream-Variante
Für wirklich große Werte (hunderte MB bis GB) hat Postgres ein zweites Mechanismus: Large Objects, mit einer eigenen API (lo_create, lo_open, lo_read, lo_write).
CREATE TABLE big_files (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
filename text NOT NULL,
blob_oid oid -- Verweis auf das Large Object
);
-- Large Object aus Datei laden (Server-seitig — braucht Superuser oder
-- pg_read_server_files)
\lo_import 'huge.zip'
-- Liefert eine OID zurück, z. B. 16384
INSERT INTO big_files (filename, blob_oid) VALUES ('huge.zip', 16384);Large Objects sind Datenbank-globale Objekte — sie leben nicht in einer Tabelle, sondern werden über oid referenziert. Anwendungen lesen sie über die Treiber-API stream-weise:
import { LargeObjectManager } from 'pg-large-object';
const man = new LargeObjectManager({ pg: client });
const [size, stream] = await man.openAndReadableStreamAsync(16384, 16384);
// stream ist ein normaler Node-Stream — pipe-barStreaming ist der Hauptvorteil — ein 5-GB-Blob kann gelesen werden, ohne dass alle 5 GB ins RAM müssen.
bytea vs. Large Objects — Vergleich
| Aspekt | bytea | Large Object |
|---|---|---|
| Größenlimit | 1 GB pro Wert (praktisch unter 50 MB) | 4 TB |
| API | Standard SQL (INSERT/SELECT) | Eigene Funktionen (lo_*) |
| In Tabelle | Direkt als Spaltenwert | Nur OID-Verweis, Daten extern |
| Streaming | Nein (alles auf einmal) | Ja |
| Backup | mit pg_dump | mit pg_dump (--blobs Default) |
| Lifecycle | mit der Zeile | unabhängig — DELETE der Zeile lässt das Large Object verwaist |
| Cleanup | automatisch | manuell (vacuumlo oder Trigger) |
| Permissions | mit Tabelle | eigenes Permission-System pro Large Object (PG 9.0+) |
Faustregel:
- Werte bis ~10 MB →
bytea. Einfacher, ein Standard-SQL. - Werte über ~100 MB und Streaming nötig → Large Object.
- Werte dazwischen → meistens
bytea, außer Streaming ist explizit gewünscht.
Aber wirklich: vorher die nächste Frage stellen.
Sollte das überhaupt in die DB?
Beide Optionen funktionieren, aber große Binär-Daten in der Datenbank haben Nachteile:
- Backups dauern länger.
pg_dumpeiner DB mit 100 GB Bildern dauert lange — und blockiert eventuell. - Replikation überträgt alles. Streaming-Replicas und logische Replikation kopieren alle Bytes mit.
- TOAST-Tabellen werden riesig und können den Cache-Hit-Rate verschlechtern für „normale” Queries.
- Backup-Versionen werden teuer. Tägliche Snapshots inklusive Bilder = Speicher-Explosion.
Die moderne Best Practice für Web-Apps:
CREATE TABLE attachments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
filename text NOT NULL,
content_type text NOT NULL,
size_bytes bigint NOT NULL,
checksum_sha256 bytea NOT NULL, -- 32 Bytes — okay in DB
storage_url text NOT NULL, -- s3://bucket/path/...
created_at timestamptz NOT NULL DEFAULT now()
);Die echte Datei wandert in S3 / GCS / Azure Blob; die DB hält nur Metadaten und Checksum. Vorteile:
- DB bleibt klein und schnell.
- Object Storage ist günstiger und auf Streaming ausgelegt.
- CDN-Integration trivial.
- Backup der DB schließt nicht TB an Binär-Daten ein.
bytea macht trotzdem Sinn für kleine Werte: Hashes (32 Bytes), kryptographische Keys (256-512 Bytes), kleine Thumbnails (paar KB). Hier ist die Indirektion zu Object Storage Overhead.
Praktische bytea-Anwendungen
CREATE TABLE files (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
filename text NOT NULL,
sha256 bytea NOT NULL UNIQUE, -- 32 Bytes, ideal für bytea
storage_url text NOT NULL
);
-- Lookup nach Hash:
SELECT * FROM files WHERE sha256 = '\x' ||
encode(digest('test', 'sha256'), 'hex');Hashes als bytea sind kompakter und schneller in Vergleichen als Hex-Strings (text mit 64 Zeichen). Für UNIQUE-Constraints oder Foreign Keys auf Hashes ist das die richtige Wahl.
CREATE EXTENSION pgcrypto;
CREATE TABLE secrets (
id bigserial PRIMARY KEY,
owner_id bigint NOT NULL,
ciphertext bytea NOT NULL
);
-- Verschluesseln beim Insert:
INSERT INTO secrets (owner_id, ciphertext)
VALUES (1, pgp_sym_encrypt('mein geheimnis', 'master-key'));
-- Entschluesseln beim Read:
SELECT pgp_sym_decrypt(ciphertext, 'master-key') FROM secrets WHERE id = 1;pgcrypto (Built-in-Extension) bietet symmetrische und asymmetrische Verschlüsselung — Werte landen als bytea in der Tabelle.
FAQ
Sind Bilder in der DB eine gute Idee?
Selten. Für sehr kleine Bilder (Avatar-Thumbnails, kleine Icons unter 50 KB) okay. Für Produktbilder, User-Uploads etc. lieber Object Storage und nur die URL in der DB. Backup-, Replikations- und Performance-Kosten überschreiten den Komfort einer einzelnen Tabelle.
Hex-Notation für bytea-Literale.
'\x89504e47'::bytea ist die Standard-Notation seit PG 9.0. Vorher war’s das veraltete „escape”-Format mit \\xxx Octal-Escapes. Wenn alte Tools sich beklagen: bytea_output = 'escape' als Session-Setting setzt es zurück (selten nötig).
Large Objects werden bei DELETE der Zeile NICHT gelöscht.
Ein häufiger Bug: DELETE FROM big_files WHERE id = 1 lässt das verwaiste Large Object zurück. Aufräumen: lo_unlink(blob_oid) separat, oder vacuumlo als Cleanup-Tool, oder einen BEFORE DELETE-Trigger auf der Referenz-Tabelle.
bytea und Encoding-Verwirrung.
bytea und text sind verschiedene Typen — auch wenn der Inhalt eines bytea lesbarer ASCII ist. 'hello'::text ist ein 5-Zeichen-String; 'hello'::bytea ist ein 5-Byte-Wert (zufällig dieselbe Repräsentation). Vergleiche zwischen beiden brauchen explizite Casts.
encode() und decode() für Konvertierung.
encode(bytea_val, 'hex') liefert einen Hex-String, 'base64' Base64, 'escape' das alte Postgres-Escape-Format. Umkehrung: decode(text_val, 'hex') etc. Praktisch in JSON-APIs, wo bytea nicht direkt serialisiert werden kann.
CHECK-Constraint auf size_bytes vermeidet Mega-Inserts.
Bei bytea-Spalten lohnt ein expliziter Check: CHECK (octet_length(data) <= 5 * 1024 * 1024) — verhindert, dass jemand versehentlich ein 500-MB-File einfügt und die DB verstopft. Das Limit dokumentiert auch die Erwartung.
Weiterführende Ressourcen
Externe Quellen
- Binary Data Types: bytea
- Large Objects
- TOAST – PostgreSQL Documentation
- pgcrypto Extension
- Binary String Functions