Codecs

What are codecs?

Codecs are a driver-aware transform layer that sits between your JavaScript values and the database. They solve the problem of different drivers returning and accepting data in different formats for the same column types.

Why are they needed?

  1. Driver differences — each PG driver (node-postgres, postgres-js, pglite, etc.) parses and serializes types differently. Codecs normalize these differences so your app code doesn’t have to care which driver you’re using.

  2. JSON context — when a column is selected inside a JSON function (like jsonAgg, jsonBuildObject, or relational queries), the database returns values in JSON format which may differ from the regular format. For example, bigint in JSON becomes a number (losing precision). Codecs handle this by casting to text before JSON-ification (castInJson) and then parsing it back (normalizeInJson).

How codecs work

Codecs have two layers: cast and normalize.


          ┌───────────────────────────────────────┐
          │  Cast layer (DB level)                │
          │  SELECT "bigint"::text FROM "users"   │
          └───────────────────────────────────────┘
 
          ┌────────────────────────────────────┐
          │  Normalize layer (Code level)      │
          │  "123" → BigInt("123") → 123n      │
          └────────────────────────────────────┘

Cast operates at the database level — it wraps a column in a SQL ::type

Normalize operates at the code level — it transforms the raw value into the type you/driver expect


Both layers can have separate variants for different query contexts:

For reads (SELECT):

SQL generation:      SELECT "col"::text FROM ...     ← cast layer modifies SQL

Database executes:   returns text representation

JS result mapping:   parseLineABC(rawValue)          ← normalize layer transforms in JS

Your code gets:      { a: 1, b: 2, c: 3 }

For writes (INSERT/UPDATE):

Your JS value:       { key: "val" }

JS param building:   JSON.stringify(value)            ← normalize layer transforms in JS

SQL generation:      INSERT ... VALUES ($1::jsonb)    ← cast layer modifies SQL

Database receives:   '{"key":"val"}' with type hint

Each layer operates across 3 contexts, each with scalar and array variants:

                        Cast (SQL)              Normalize (JS)
                   ┌──────────────────┐    ┌──────────────────────┐
  Regular SELECT   │ cast             │    │ normalize            │
                   │ castArray        │    │ normalizeArray       │
                   ├──────────────────┤    ├──────────────────────┤
  Inside JSON      │ castInJson       │    │ normalizeInJson      │
  (jsonAgg, RQB)   │ castArrayInJson  │    │ normalizeArrayInJson │
                   ├──────────────────┤    ├──────────────────────┤
  Params           │ castParam        │    │ normalizeParam       │
  (INSERT/UPDATE)  │ castArrayParam   │    │ normalizeParamArray  │
                   └──────────────────┘    └──────────────────────┘

JSON context exists because databases serialize values differently inside JSON — a bigint that normally comes back as a string from the driver will come back as a lossy number inside a JSON object. So codecs need separate handling: cast to ::text in SQL before JSON wrapping, then parse the text back in JS after.


MethodWhenExample SQL
castColumn in SELECT"col"::text
castArrayArray column in SELECT"col"::text[]
castInJsonColumn inside JSON functions"col"::text (inside json_agg)
castArrayInJsonArray column inside JSON
castParamParam placeholder in INSERT/UPDATE/WHERE$1::date
castArrayParamArray param placeholder$1::date[]

MethodWhenExample
normalizeSELECT result → JS value"123"123n
normalizeArraySELECT array result → JS array["123", "456"][123n, 456n]
normalizeInJsonSELECT result inside JSON → JSJSON bigintBigInt
normalizeArrayInJsonSELECT array inside JSON → JS
normalizeParamJS value → driver param (INSERT/UPDATE/WHERE){ key: "val" }'{"key":"val"}'
normalizeParamArrayJS array → driver array param[1, 2]{1,2} (PG array literal)

Are they enabled by default?

Yes. Every driver ships default codecs. When you call drizzle(client), the driver’s codecs are automatically used.
You don’t need to do anything to enable them.

How codecs work in built-in column types

Every built-in PG column class declares a codec string identifier:

// pg-core/columns/integer.ts
class PgInteger extends PgColumn {
  override readonly codec = 'int';
}

// pg-core/columns/bigint.ts  
class PgBigInt53 extends PgColumn {
  override readonly codec = 'bigint:number';
}
class PgBigInt64 extends PgColumn {
  override readonly codec = 'bigint';
}
class PgBigIntString extends PgColumn {
  override readonly codec = 'bigint:string';
}

// pg-core/columns/line.ts
class PgLineABC extends PgColumn {
  override readonly codec = 'line';
}
class PgLineTuple extends PgColumn {
  override readonly codec = 'line:tuple'
}

...

This identifier is a lookup key into the driver’s codec map. If the driver defines transforms for that key, they’re applied. If not, the value passes through untouched.

For example, integer has codec 'int'. The node-postgres codec map has no entry for 'int' — integers don’t need any transformation. But bigint has codec 'bigint', and node-postgres defines:

// node-postgres/codecs.ts
bigint: {
  normalize: BigInt,                           // SELECT: "123" → 123n
  normalizeArray: arrayCompatNormalize(BigInt), // SELECT array: ["123"] → [123n]
}

Overriding codecs for built-in types

Built-in columns (like integer(), bigint(), date(), text(), etc.) have a hardcoded codec string — you can’t change which codec identifier a column uses. But you can change what that codec identifier does by overriding the driver’s codec map.

Every PG driver accepts a codecs option in drizzle():

const db = drizzle(client, {
  codecs: {
    "bigint:number": {
      cast: (name) => sql`${name}::text`,
      normalize: BigInt,
    },
  },
});

How codecs work in customType()

Note

toDriver/fromDriver on customType and codecs are separate layers. toDriver/fromDriver are per-column instance transforms. Codecs are driver-level transforms and both are applied
On reads - codec normalize first → then fromDriver On writes - toDriver first → then codec normalizeParam

Read more about custom types here

When defining a custom column type, you can specify which codec to use via the codec property:

import { customType } from 'drizzle-orm/pg-core';

// Use an existing codec by name
const customBigint = customType<{ data: bigint; driverData: string }>({
  dataType() {
    return "bigint";
  },
  fromDriver(value: string) {
    return BigInt(Number(value) * 1000); // ← extra column mapping logic
  },
  codec: "bigint", // ← uses the driver's bigint codec
});

// Codec as a function (useful when codec depends on config)
const customDataType = customType<{
  data: number;
  driverData: bigint | number;
  config?: { mode: "bigint" | "number" };
  configRequired: true;
}>({
  dataType() {
    return `custom_type`;
  },
  fromDriver(value: bigint | number) {
    return Number(value) / 1000; // ← extra column mapping logic
  },
  codec: (config) => {
    // ← can be dynamic based on config
    if (!config || config.mode === "bigint") {
      return "bigint";
    }

    return "bigint:number";
  },
});

// No codec — skip codec transforms entirely
const rawCustom = customType<{ data: string; driverData: string }>({
  dataType() {
    return 'my_type';
  },
  // codec is undefined by default — no codec transforms applied
});

The codec field accepts: