27 Datentypen in MongoDB: Mehr als nur JSON

MongoDB’s Datenmodell basiert auf Dokumenten, und Dokumente bestehen aus Feldern mit Werten. Diese Werte haben Typen – String, Number, Boolean, und viele weitere. Die Typen-Palette ist reichhaltiger als in JSON, weil MongoDB BSON nutzt. Während JSON mit sechs simplen Typen auskommt, bietet BSON über zwanzig, von ObjectId über Decimal128 bis zu spezialisierten Typen wie MinKey und MaxKey.

Diese Typen-Vielfalt ist nicht akademischer Luxus, sondern praktische Notwendigkeit. Dates als Strings zu speichern mag in JavaScript-Prototypen funktionieren, führt aber in produktiven Systemen zu Chaos mit Timezones, Parsing-Fehlern und ineffizienten Queries. Binary Data als Base64-Strings zu kodieren verschwendet 33% Speicher. Finanzbeträge als Floating-Point zu speichern produziert Rundungsfehler, die niemand will. BSON’s erweiterte Typen lösen diese Probleme – wenn man weiß, wann und wie man sie nutzt.

27.1 Die JSON-Basis-Typen: Vertraut aber limitiert

MongoDB unterstützt alle Standard-JSON-Typen, weil BSON eine Superset von JSON ist. Die fundamentalen sechs sind universell und intuitiv.

String ist der simpelste Typ – eine Sequenz von Unicode-Charakteren. In BSON werden Strings als UTF-8 kodiert, prefixed mit ihrer Länge in Bytes. Dies erlaubt effizientes String-Handling ohne null-Terminierung scannen zu müssen. MongoDB’s Strings haben praktisch keine Längenbeschränkung (außer dem 16-MB-Dokumentlimit), aber sehr lange Strings in häufig queried Feldern können Performance beeinträchtigen. Ein description-Feld mit 10.000 Zeichen ist okay, ein username mit 10.000 Zeichen ist problematisch.

db.products.insertOne({
  name: "Wireless Mouse",
  description: "Ergonomic design with 2.4GHz wireless connectivity...",
  sku: "WM-2024-001"
})

String-Vergleiche in MongoDB sind standardmäßig case-sensitive und locale-aware. Queries wie db.users.find({ name: "alice" }) matchen nicht “Alice” oder “ALICE”. Für case-insensitive Searches muss man Collations nutzen oder RegEx:

// Case-insensitive via RegEx
db.users.find({ name: /^alice$/i })

// Oder via Collation (effizienter für große Datasets)
db.users.find({ name: "alice" }).collation({ locale: "en", strength: 2 })

Number in JSON ist bewusst vage – kein Unterschied zwischen Integer und Float. In BSON wird dies differenziert. Die Shell interpretiert literals wie 42 als 64-bit Double (Float), nicht als Integer. Für explizite Integer-Typen muss man NumberInt() oder NumberLong() nutzen:

// Als Double gespeichert (standard)
db.counters.insertOne({ count: 42 })

// Als 32-bit Integer
db.counters.insertOne({ count: NumberInt(42) })

// Als 64-bit Integer
db.counters.insertOne({ count: NumberLong(42) })

Der Unterschied ist nicht nur semantisch. Integer-Operationen sind schneller und kompakter. Ein 32-bit Int braucht 4 Bytes, ein Double 8 Bytes. Bei Millionen Dokumenten summiert sich dies. Außerdem: Integer-Vergleiche sind exakt, Float-Vergleiche können durch Rundung fehlschlagen.

Boolean ist trivial – true oder false, gespeichert als einzelnes Byte. Aber ein häufiger Fehler: Truthy/Falsy-Semantik aus JavaScript übertragen. In MongoDB ist 0 nicht false, und "" (empty string) nicht false. Nur das literal false ist false. Queries müssen explizit sein:

// Falsch: Findet NICHT Dokumente mit isActive: false
db.users.find({ isActive: { $ne: true } })

// Korrekt: Explizit false matchen
db.users.find({ isActive: false })

Array ist eine geordnete Liste beliebiger Werte, inklusive mixed Types. MongoDB erlaubt Queries auf Array-Elemente mit spezieller Syntax:

db.products.insertOne({
  name: "Laptop",
  tags: ["electronics", "computers", "portable"]
})

// Findet Dokumente, wo "electronics" in tags Array ist
db.products.find({ tags: "electronics" })

// Findet Dokumente, wo tags mindestens zwei Elemente hat
db.products.find({ tags: { $size: 2 } })

// Findet Dokumente, wo erstes tag-Element "electronics" ist
db.products.find({ "tags.0": "electronics" })

Arrays können Objekte enthalten, was nested Queries erlaubt. Ein typisches Pattern: Embedded Subdocuments in Arrays:

db.orders.insertOne({
  orderId: "ORD-123",
  items: [
    { productId: "PROD-1", quantity: 2, price: 49.99 },
    { productId: "PROD-2", quantity: 1, price: 149.99 }
  ]
})

// Query auf nested Array-Felder
db.orders.find({ "items.productId": "PROD-1" })

Object (oder Embedded Document) erlaubt beliebige Verschachtelung. MongoDB unterstützt Dot-Notation für Queries auf nested Felder:

db.users.insertOne({
  username: "alice",
  profile: {
    firstName: "Alice",
    lastName: "Smith",
    address: {
      city: "Berlin",
      country: "DE"
    }
  }
})

// Query auf dreifach-nested Feld
db.users.find({ "profile.address.city": "Berlin" })

Die Verschachtelungstiefe ist praktisch unbegrenzt (bis zum 16-MB-Dokumentlimit), aber tiefe Verschachtelung macht Queries komplex und schwer zu indexieren. Best Practice: Maximal 2-3 Ebenen tief.

null ist ein spezieller Wert, der “explizit kein Wert” signalisiert. Er ist verschieden von undefined (fehlendes Feld). Queries müssen dies unterscheiden:

db.users.insertOne({ name: "Alice", nickname: null })
db.users.insertOne({ name: "Bob" })  // nickname fehlt

// Findet Dokumente, wo nickname null ist oder fehlt
db.users.find({ nickname: null })

// Findet nur Dokumente, wo nickname explizit existiert und null ist
db.users.find({ nickname: { $type: "null" } })

// Findet Dokumente, wo nickname fehlt
db.users.find({ nickname: { $exists: false } })

Diese Nuance ist häufige Fehlerquelle. { field: null } matched sowohl field: null als auch fehlendes field. Für explizites null muss man $type: "null" kombinieren mit $exists: true.

27.2 BSON’s Crown Jewel: ObjectId

Der ObjectId-Typ ist MongoDB’s Standard für _id-Felder. Ein ObjectId ist ein 12-Byte-Wert, designed als global unique identifier ohne zentrale Koordination. Die Struktur:

ObjectId("507f1f77bcf86cd799439011")
//       └─────┬─────┘└──┬──┘└─┬─┘
//       Timestamp  Random Counter

Der eingebettete Timestamp ist nützlich für Sortierung. ObjectIds sind sortierbar nach Creation-Time ohne separates createdAt-Feld:

// Findet alle Dokumente, created nach bestimmtem Zeitpunkt
const cutoffDate = new Date("2024-01-01T00:00:00Z")
const cutoffId = ObjectId(Math.floor(cutoffDate / 1000).toString(16) + "0000000000000000")

db.users.find({ _id: { $gt: cutoffId } })

ObjectIds sind kompakt (12 Bytes vs. 36 Bytes für UUID-Strings) und schnell zu generieren. Die Random-Komponente garantiert, dass selbst bei synchronisierten Clocks verschiedene Prozesse unterschiedliche IDs generieren. Der Counter verhindert Kollisionen bei Tausenden IDs pro Sekunde vom selben Prozess.

Man kann eigene IDs verwenden statt ObjectId – Strings, Numbers, sogar Compound-Keys. Aber ObjectIds sind der Default, und viele Tools und Best Practices assumieren sie. Wenn man eigene IDs nutzt, sollte man sicherstellen, dass sie unique, monoton steigend (für Performance) und kompakt sind.

27.3 Date: Zeit richtig speichern

Der Date-Typ ist ein 64-bit Integer, der Millisekunden seit Unix-Epoch (1970-01-01T00:00:00Z) repräsentiert. Dies ist präzise, sortierbar und timezone-unabhängig. MongoDB’s Shell zeigt Dates als ISODate("...") für Lesbarkeit, aber intern ist es ein Integer.

db.events.insertOne({
  name: "Conference",
  startTime: new Date("2024-06-15T09:00:00Z"),
  endTime: new Date("2024-06-15T17:00:00Z")
})

// Range-Query auf Dates
db.events.find({
  startTime: {
    $gte: new Date("2024-06-01"),
    $lt: new Date("2024-07-01")
  }
})

Date-Arithmetik ist trivial, weil es nur Integer-Arithmetik ist:

// Finde Events in den nächsten 7 Tagen
const now = new Date()
const weekLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)

db.events.find({
  startTime: { $gte: now, $lte: weekLater }
})

Die Präzision ist Millisekunden. Für Mikrosekunden oder Nanosekunden muss man separate Felder oder Custom-Encoding nutzen. Aber für 99% der Anwendungen ist Millisekunden-Präzision ausreichend.

Ein häufiger Fehler: Dates als Strings speichern. Dies scheint zu funktionieren, weil String-Vergleich oft lexikographisch korrekt ist für ISO-8601-Strings. Aber es ist fragil (non-ISO-Formate brechen), ineffizient (String-Vergleich langsamer als Integer) und verhindert Date-spezifische Operationen:

// Schlecht: Date als String
db.events.insertOne({ date: "2024-06-15T09:00:00Z" })

// Kann nicht Date-Operationen nutzen wie $dateToString, $year, $month

Immer native Date-Typen verwenden für zeitbasierte Daten.

27.4 Decimal128: Präzision für Finanzdaten

Floating-Point-Arithmetik ist berüchtigt für Rundungsfehler. Der klassische Fehler: 0.1 + 0.2 = 0.30000000000000004. Für Finanzberechnungen ist dies inakzeptabel. Ein Cent-Fehler bei Millionen Transaktionen ist ein finanzielles Desaster.

Decimal128 ist die Lösung. Es ist ein 128-bit Dezimal-Typ mit 34 signifikanten Digits Präzision. Zahlen wie 19.99 werden exakt gespeichert, ohne Floating-Point-Approximation:

db.products.insertOne({
  name: "Widget",
  price: NumberDecimal("19.99"),
  taxRate: NumberDecimal("0.19")
})

// Berechnung ist exakt, keine Rundungsfehler
db.products.aggregate([
  {
    $project: {
      name: 1,
      totalPrice: {
        $multiply: ["$price", { $add: [1, "$taxRate"] }]
      }
    }
  }
])

Decimal128 ist langsamer als Double (Floating-Point) – die Operationen sind Software-implementiert, nicht Hardware. Aber für Finanzdaten ist Korrektheit wichtiger als Speed. Jede E-Commerce-Plattform, jedes Payment-System sollte Decimal128 für Beträge nutzen.

Der häufigste Fehler: Decimals mit Doubles mischen. MongoDB konvertiert nicht automatisch. Queries müssen Type-aware sein:

// Falsch: Vergleich mit Double
db.products.find({ price: 19.99 })  // Matched nicht NumberDecimal("19.99")

// Korrekt: Vergleich mit Decimal
db.products.find({ price: NumberDecimal("19.99") })

Best Practice: Decimal128 für alle monetären Werte, Prozentsätze und andere Zahlen, wo exakte Präzision nötig ist.

27.5 BinData: Binäre Effizienz

Binary Data – Images, PDFs, verschlüsselte Blobs – muss irgendwo gespeichert werden. JSON hat keine native Binary-Unterstützung, also nutzt man Base64-Encoding. BSON hat BinData, das rohe Bytes direkt speichert.

// Binary Data speichern (z.B. ein kleines Icon)
const binaryData = new BinData(0, "base64EncodedDataHere...")

db.users.insertOne({
  username: "alice",
  avatar: binaryData
})

Der erste Parameter von BinData ist ein Subtype-Code: - 0: Generic binary - 3: UUID - 4: MD5 hash - 5: Encrypted data - 128-255: User-defined

Für große Files sollte man GridFS nutzen, nicht direkte BinData in Dokumenten. GridFS ist designed für Files über 16 MB und erlaubt Streaming. Aber für kleine Binary Blobs – Thumbnails, Signatures, Encrypted Tokens – ist BinData effizienter als separate Storage.

27.6 Spezialisierte Typen: Regex, MinKey, MaxKey

Regex speichert reguläre Ausdrücke nativ. Queries können direkt Pattern-Matching nutzen:

db.users.insertOne({
  username: "alice",
  emailPattern: /^[a-z]+@example\.com$/i
})

// Query mit stored Regex
db.users.find({ email: { $regex: emailPattern } })

Stored Regexes sind selten genutzt, aber nützlich für Templates oder Validation Rules, die in der Datenbank selbst definiert sind.

MinKey und MaxKey sind spezielle Boundary-Werte. MinKey ist kleiner als jeder andere BSON-Wert, MaxKey größer. Sie sind nützlich für Range-Definitions in Sharding oder für Queries, die absolute Minimums/Maximums finden:

// Definiere einen Shard-Range von MinKey bis bestimmtem Wert
sh.addShardToZone("shard1", "range1")
sh.updateZoneKeyRange("myDB.myCollection",
  { shardKey: MinKey },
  { shardKey: 1000 },
  "range1"
)

Diese Typen sind Edge-Cases, aber sie existieren, und man sollte wissen, was sie bedeuten, wenn man sie in Konfigurationen oder Logs sieht.

27.7 Type Coercion und Type Confusion: Häufige Fallstricke

MongoDB ist type-aware, nicht type-coercive. "42" (String) und 42 (Number) sind verschiedene Werte und matchen nicht in Queries. Dies ist fundamental anders als JavaScript, wo loose Equality (==) konvertiert.

Ein häufiges Problem bei Daten-Migrationen oder External-Imports:

// Import aus CSV, alles wird als Strings geparst
db.products.insertOne({ price: "19.99", quantity: "100" })

// Diese Query findet nichts, weil price String ist
db.products.find({ price: { $lt: 50 } })

// Muss explizit konvertieren oder Type-aware querien
db.products.find({ price: { $type: "string", $lt: "50" } })  // String-Vergleich

Die Lösung: Schema-Validation oder explizite Type-Checks beim Insert. MongoDB 5.0+ erlaubt Schema-Validation mit JSON Schema:

db.createCollection("products", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["price", "quantity"],
      properties: {
        price: {
          bsonType: "decimal",
          description: "must be a decimal and is required"
        },
        quantity: {
          bsonType: "int",
          description: "must be an integer and is required"
        }
      }
    }
  }
})

Mit Validation werden Inserts mit falschen Types rejected. Dies verhindert Type-Confusion proaktiv.

Die folgende Tabelle fasst die wichtigsten Typen zusammen:

Typ BSON Type Code Bytes Primärer Use-Case Fallstricke
String 2 Variable Text, IDs Case-sensitivity
Int32 16 4 Zähler, Flags Auto-Casting zu Double
Int64 18 8 Große Zahlen Overflow bei 32-bit
Double 1 8 Messungen, Scores Rundungsfehler
Decimal128 19 16 Geld, Prozente Performance
Boolean 8 1 Flags Keine Truthy-Semantik
Date 9 8 Timestamps Timezone-Confusion
ObjectId 7 12 Primary Keys Embedded Timestamp
BinData 5 Variable Files, Blobs 16 MB Limit
Array 4 Variable Listen Mixed-Type-Queries
Object 3 Variable Nested Data Deep Nesting
null 10 0 Fehlende Werte Vs. undefined
Regex 11 Variable Patterns Performance bei Full-Scan

MongoDB’s Typen-System ist reichhaltig und mächtig, aber es erfordert Bewusstsein. Type Confusion – Strings wo Numbers sein sollten, Doubles wo Decimals nötig sind – ist eine häufige Fehlerquelle. Schema Validation, explizite Type-Checks beim Insert und Type-aware Queries sind essentiell für robuste Anwendungen. Die erweiterten BSON-Typen sind nicht optional für ernsthafte Produktionssysteme. Sie sind der Unterschied zwischen einem fragilen Prototyp und einer robusten, korrekten Datenbank.