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

SQL Tabelle mit bytea-Spalte
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.

SQL Werte einfügen — Hex-Format
-- 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:

JavaScript node-postgres
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]
);
Go pgx
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).

SQL TOAST-Strategie pro Spalte sehen
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).

SQL Tabelle mit OID auf Large Object
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:

JavaScript node-postgres mit pg-large-object
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-bar

Streaming ist der Hauptvorteil — ein 5-GB-Blob kann gelesen werden, ohne dass alle 5 GB ins RAM müssen.

bytea vs. Large Objects — Vergleich

AspektbyteaLarge Object
Größenlimit1 GB pro Wert (praktisch unter 50 MB)4 TB
APIStandard SQL (INSERT/SELECT)Eigene Funktionen (lo_*)
In TabelleDirekt als SpaltenwertNur OID-Verweis, Daten extern
StreamingNein (alles auf einmal)Ja
Backupmit pg_dumpmit pg_dump (--blobs Default)
Lifecyclemit der Zeileunabhängig — DELETE der Zeile lässt das Large Object verwaist
Cleanupautomatischmanuell (vacuumlo oder Trigger)
Permissionsmit Tabelleeigenes 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_dump einer 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:

SQL Empfohlenes Pattern
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

SQL Hash-Speicherung
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.

SQL Verschluesselte Felder
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

/ Weiter

Zurück zu Datentypen

Zur Übersicht