JSON Columns in Drizzle ORM: jsonb, Queries, and Type Safety
Last updated:
Drizzle ORM defines JSON columns with json('col') or jsonb('col') and infers TypeScript types from .$type<MyInterface>(). Unlike raw SQL, Drizzle's type system catches JSON shape mismatches at compile time. For PostgreSQL JSON path queries, use the sql tagged template literal: sql`${table.col}->>'key'`. Drizzle supports both PostgreSQL jsonb (indexed, faster) and MySQL json columns with the same API. For runtime validation of data read from the database, pair Drizzle with Zod via drizzle-zod. This guide covers 5 topics: defining JSON columns, inserting and selecting typed JSON, querying with JSON path operators, custom JSON transformations, and Zod integration for runtime validation.
Define JSON Columns in Drizzle
Bottom line: use jsonb('col') for PostgreSQL (binary, indexable, faster) and json('col') for MySQL. Chain .$type<T>() to give the column a compile-time TypeScript type. Without .$type<>(), reads return unknown.
Both json and jsonb are imported from "drizzle-orm/pg-core" for PostgreSQL, or json alone from "drizzle-orm/mysql-core" for MySQL (MySQL has only one JSON type). The .$type<T>() generic is a pure TypeScript overlay — it does not affect the generated SQL or the database schema. Drizzle automatically calls JSON.stringify on write and JSON.parse on read; you never handle serialization manually. If you need a default value, chain .default() or .notNull() as with any other column type.
import { pgTable, serial, text, jsonb, timestamp } from "drizzle-orm/pg-core"
import { mysqlTable, int, varchar, json } from "drizzle-orm/mysql-core"
// --- TypeScript interfaces for the JSON columns ---
interface UserMetadata {
plan: "free" | "pro" | "enterprise"
features: string[]
createdAt: string
}
interface OrderLineItem {
productId: number
quantity: number
price: number
}
// --- PostgreSQL: use jsonb for indexed, binary-stored JSON ---
export const usersTable = pgTable("users", {
id: serial("id").primaryKey(),
email: text("email").notNull().unique(),
// jsonb with TypeScript type — reads return UserMetadata | null
metadata: jsonb("metadata").$type<UserMetadata>(),
})
// --- PostgreSQL: array-of-objects in jsonb ---
export const ordersTable = pgTable("orders", {
id: serial("id").primaryKey(),
userId: int("user_id").notNull(),
lineItems: jsonb("line_items").$type<OrderLineItem[]>().notNull(),
createdAt: timestamp("created_at").defaultNow(),
})
// --- MySQL: json() (MySQL has a single JSON type) ---
export const productsTable = mysqlTable("products", {
id: int("id").primaryKey().autoincrement(),
name: varchar("name", { length: 255 }).notNull(),
attributes: json("attributes").$type<Record<string, string>>(),
})
// --- json() without .$type<>() — reads return unknown ---
export const logsTable = pgTable("logs", {
id: serial("id").primaryKey(),
payload: jsonb("payload"), // type is unknown — consider adding .$type<>()
})Insert and Select Typed JSON
Bottom line: insert a plain JavaScript object — Drizzle serializes it. Select returns the typed object from .$type<T>() with no extra parsing step needed.
When you call db.insert(table).values({...}), the TypeScript compiler checks the JSON column value against T from .$type<T>() at compile time. At runtime, Drizzle calls JSON.stringify and sends the result to the database driver. On select, the driver returns a string which Drizzle parses back to an object — so the value you receive is a fully typed object matchingT. You can filter in a .where() clause on the column's top-level value, but for nested path queries you need the sql tagged template (covered in the next section).
import { db } from "./db" // your Drizzle client
import { usersTable, ordersTable } from "./schema"
import { eq } from "drizzle-orm"
// --- Insert: TypeScript checks the metadata shape ---
await db.insert(usersTable).values({
email: "alice@example.com",
metadata: {
plan: "pro",
features: ["analytics", "api-access"],
createdAt: new Date().toISOString(),
},
})
// --- Select all columns: metadata is typed as UserMetadata | null ---
const users = await db.select().from(usersTable)
// users[0].metadata?.plan → "pro"
// --- Select specific columns ---
const emails = await db
.select({ email: usersTable.email, metadata: usersTable.metadata })
.from(usersTable)
// --- Where on a top-level typed value (not a JSON path) ---
// This works if the entire column equals a JSON value, but path filtering
// requires the sql operator (see next section)
// --- Insert array-of-objects into jsonb ---
await db.insert(ordersTable).values({
userId: 1,
lineItems: [
{ productId: 42, quantity: 2, price: 9.99 },
{ productId: 55, quantity: 1, price: 24.50 },
],
})
// --- Read back typed array ---
const [order] = await db
.select()
.from(ordersTable)
.where(eq(ordersTable.id, 1))
const total = order.lineItems.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
console.log("Order total:", total.toFixed(2))Query JSON with Path Operators
Bottom line: Drizzle passes sql tagged template expressions through to the database as raw SQL. Use sql`${table.col}->>'key'` for PostgreSQL text extraction andsql`JSON_EXTRACT(${table.col}, '$.key')` for MySQL.
PostgreSQL has two JSON operators: -> extracts a value as a JSON type, and ->> extracts it as text (suitable for .where() string comparisons). For deeply nested keys, chain operators: table.col->'outer'->>'inner'. The @> containment operator checks if the left jsonb value contains the right — useful for querying by a subset of JSON. MySQL uses JSON_EXTRACT(col, '$.key') or the shorthand col->'$.key'. Wrap any sql expression in eq(), gt(), or other Drizzle operators, or use it directly in .where() as a boolean expression.
import { db } from "./db"
import { usersTable, ordersTable } from "./schema"
import { sql, eq, and, gt } from "drizzle-orm"
// --- PostgreSQL: ->> extracts a JSON field as text ---
const proUsers = await db
.select()
.from(usersTable)
.where(sql`${usersTable.metadata}->>'plan' = 'pro'`)
// --- PostgreSQL: nested path ---
const withFeature = await db
.select()
.from(usersTable)
.where(sql`${usersTable.metadata}->'features' ? 'api-access'`)
// --- PostgreSQL: @> containment (jsonb only) ---
// Finds rows where metadata contains {"plan":"enterprise"}
const enterprise = await db
.select()
.from(usersTable)
.where(sql`${usersTable.metadata} @> '{"plan":"enterprise"}'::jsonb`)
// --- Combine with Drizzle operators ---
const recent = await db
.select()
.from(usersTable)
.where(
and(
sql`${usersTable.metadata}->>'plan' != 'free'`,
gt(usersTable.id, 100)
)
)
// --- Extract a JSON field as a select column ---
const plans = await db
.select({
email: usersTable.email,
plan: sql<string>`${usersTable.metadata}->>'plan'`,
})
.from(usersTable)
// plans[0].plan is typed as string because of sql<string>
// --- MySQL: JSON_EXTRACT ---
// import { productsTable } from "./schema"
// const red = await db
// .select()
// .from(productsTable)
// .where(sql`JSON_EXTRACT(${productsTable.attributes}, '$.color') = 'red'`)Update Nested JSON Fields
Bottom line: Drizzle has no built-in partial JSON update helper. The safest approach is a full-document replace: read the row, merge in JavaScript, write it back. For server-side partial updates without a round-trip, use PostgreSQL's jsonb_set() or MySQL's JSON_SET() via thesql operator.
Full-document replace is simple and works across all databases, but requires a read-then-write round-trip. For high-concurrency updates, add an optimistic concurrency check: read a version column, increment it on write, and add a .where(eq(table.version, readVersion)) clause. If the update affects 0 rows, another process updated first — retry. For server-side updates, jsonb_set(col, '{path}', value) lets PostgreSQL merge the change atomically. Pass a JSON path array as the second argument and a ::jsonb-cast value as the third.
import { db } from "./db"
import { usersTable } from "./schema"
import { eq, sql } from "drizzle-orm"
// --- Full-document replace (safest, any database) ---
async function upgradePlan(userId: number, newPlan: "pro" | "enterprise") {
const [user] = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, userId))
if (!user || !user.metadata) throw new Error("User not found")
await db
.update(usersTable)
.set({ metadata: { ...user.metadata, plan: newPlan } })
.where(eq(usersTable.id, userId))
}
// --- PostgreSQL jsonb_set: atomic server-side update ---
// jsonb_set(target, path, new_value, create_if_missing)
await db
.update(usersTable)
.set({
metadata: sql`jsonb_set(${usersTable.metadata}, '{plan}', '"enterprise"'::jsonb, true)`,
})
.where(eq(usersTable.id, 42))
// --- Append to a jsonb array field without reading first ---
await db
.update(usersTable)
.set({
metadata: sql`
jsonb_set(
${usersTable.metadata},
'{features}',
(${usersTable.metadata}->'features') || '["sso"]'::jsonb
)
`,
})
.where(eq(usersTable.id, 42))
// --- Optimistic concurrency with version column ---
// Add: version: integer("version").notNull().default(0) to the table schema
// Then on update:
// const result = await db
// .update(usersTable)
// .set({ metadata: newMetadata, version: sql`version + 1` })
// .where(and(eq(usersTable.id, userId), eq(usersTable.version, readVersion)))
// if (result.rowCount === 0) throw new Error("Concurrent update detected")Drizzle-Zod for Runtime Validation
Bottom line: drizzle-zod generates Zod schemas directly from Drizzle table definitions. Use createSelectSchema(table) to validate rows returned by queries, and createInsertSchema(table) to validate request bodies before inserting.
Install with npm install drizzle-zod zod. The generated schemas derive column types from Drizzle's TypeScript inference — including .$type<T>() for JSON columns. For JSON columns, the generated Zod type is z.unknown() unless you extend the schema. Override with .extend({ metadata: metadataZodSchema }) to get full runtime shape validation. Use.partial() on the insert schema to make all fields optional for PATCH endpoints. ThesafeParse result surfaces field-level errors you can return directly as API validation responses.
import { createSelectSchema, createInsertSchema } from "drizzle-zod"
import { z } from "zod"
import { usersTable } from "./schema"
import { db } from "./db"
// --- Zod schema for UserMetadata ---
const userMetadataSchema = z.object({
plan: z.enum(["free", "pro", "enterprise"]),
features: z.array(z.string()),
createdAt: z.string().datetime(),
})
// --- createSelectSchema: for rows read from the DB ---
const selectUserSchema = createSelectSchema(usersTable).extend({
metadata: userMetadataSchema.nullable(), // override the json column
})
// --- createInsertSchema: for validated insert payloads ---
const insertUserSchema = createInsertSchema(usersTable).extend({
metadata: userMetadataSchema.optional(),
})
// --- Runtime-validate a query result ---
async function getUserById(id: number) {
const rows = await db.select().from(usersTable)
const result = selectUserSchema.safeParse(rows[0])
if (!result.success) {
console.error("DB row failed validation:", result.error.flatten())
throw new Error("Data integrity error")
}
return result.data // fully typed and runtime-validated
}
// --- Validate an API request body before inserting ---
async function createUser(rawBody: unknown) {
const result = insertUserSchema.safeParse(rawBody)
if (!result.success) {
return { error: result.error.flatten().fieldErrors }
}
await db.insert(usersTable).values(result.data)
return { success: true }
}
// --- Infer the TypeScript type from the Zod schema ---
type SelectUser = z.infer<typeof selectUserSchema>
// SelectUser.metadata → UserMetadata | null (runtime-validated)GIN Indexes for JSON Search
Bottom line: define a GIN index on a jsonb column in Drizzle's third table argument using index('name').using('gin').on(table.col). The migration Drizzle generates adds aUSING gin clause. GIN indexes accelerate the @> containment and ? existence operators — without one, PostgreSQL scans every row.
GIN (Generalized Inverted Index) decomposes each jsonb value into its key-value pairs and builds an inverted index mapping each JSON element to the rows that contain it. This makes @> containment queries O(log n) instead of O(n). For a table with millions of rows and jsonb columns with 10–50 keys, a GIN index can reduce query time from seconds to milliseconds. GIN indexes have higher write overhead than B-tree indexes — they're best for columns that are read-heavy and updated less frequently. For expression indexes on a specific JSON path (e.g. index only the plan field), use a B-tree index with asql expression.
import { pgTable, serial, text, jsonb, integer, index } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
interface Metadata {
plan: string
features: string[]
tags: string[]
}
// --- GIN index on the full jsonb column ---
export const usersTable = pgTable(
"users",
{
id: serial("id").primaryKey(),
email: text("email").notNull().unique(),
metadata: jsonb("metadata").$type<Metadata>().notNull(),
},
(table) => [
index("idx_users_metadata_gin").using("gin").on(table.metadata),
]
)
// Generated migration SQL:
// CREATE INDEX idx_users_metadata_gin ON users USING gin(metadata);
// --- B-tree expression index on a specific JSON path ---
// (faster for equality queries on a single field, no GIN overhead)
export const usersWithPlanIndex = pgTable(
"users_plan_idx_example",
{
id: serial("id").primaryKey(),
metadata: jsonb("metadata").$type<Metadata>().notNull(),
},
(table) => [
// B-tree index on the extracted "plan" text field
index("idx_users_plan").on(sql`(${table.metadata}->>'plan')`),
]
)
// --- Using the GIN index: @> containment query ---
import { db } from "./db"
// This query hits the GIN index:
const proUsers = await db
.select()
.from(usersTable)
.where(sql`${usersTable.metadata} @> '{"plan":"pro"}'::jsonb`)
// Existence operator ? (does the key exist?) also uses GIN:
const withTags = await db
.select()
.from(usersTable)
.where(sql`${usersTable.metadata} ? 'tags'`)
// jsonb_path_exists for JSONPath (PostgreSQL 12+):
const hasApiAccess = await db
.select()
.from(usersTable)
.where(sql`jsonpath_exists(${usersTable.metadata}, '$.features[*] ? (@ == "api-access")')`)FAQ
How do I define a JSON column in Drizzle ORM?
Use jsonb("col") for PostgreSQL or json("col") for MySQL, imported from "drizzle-orm/pg-core" or "drizzle-orm/mysql-core". Chain .$type<YourInterface>() for compile-time TypeScript safety. Drizzle handles serialization and deserialization automatically — no manual JSON.stringify calls needed.
How do I query a JSON field value in Drizzle ORM?
Use the sql tagged template literal. For PostgreSQL text extraction: sql`${table.col}->>'key'`. For containment: sql`${table.col} @> '{"key":"val"}'::jsonb`. For MySQL: sql`JSON_EXTRACT(${table.col}, '$.key')`. These can be used in .where(), .select(), and .orderBy().
What is the difference between json() and jsonb() in Drizzle?
json() maps to PostgreSQL's text-stored json type; jsonb() maps to the binary jsonb type that is 10–30% faster for path queries and supports GIN indexing. For MySQL, only json() exists. Use jsonb() for all new PostgreSQL columns unless you specifically need key-order preservation or byte-for-byte fidelity.
How do I add TypeScript types to a JSON column in Drizzle?
Chain .$type<T>() on the column definition: jsonb("metadata").$type<UserMetadata>(). This adds compile-time type checking on inserts and makes selects return T | null. There is no runtime enforcement — use drizzle-zod for that.
How do I use Drizzle with Zod for JSON validation?
Install drizzle-zod and use createSelectSchema(table) and createInsertSchema(table). Extend the generated schema with .extend({ col: myZodSchema }) to override the JSON column type from z.unknown() to a specific shape. Call schema.safeParse(row) after every database read to catch shape mismatches from schema drift.
How do I index a JSON column in Drizzle ORM?
Add index('idx_name').using('gin').on(table.col) in the table's third argument (the extras callback). This generates a CREATE INDEX ... USING gin(col) migration. GIN indexes accelerate @> containment and ? existence queries on jsonb columns. For single-field path queries, a B-tree expression index on sql`(col->>'key')` is more efficient.
Using TypeORM or Sequelize instead?
The same JSONB column patterns — path queries, GIN indexes, TypeScript generics — apply to all three ORMs. See the equivalent guides for JSON in TypeORM and JSON in Sequelize.
Open JSON ValidatorFurther reading and primary sources
- Drizzle ORM column types — Official Drizzle reference for all PostgreSQL column types including json() and jsonb(), their options, and TypeScript inference
- Drizzle SQL operator — Official Drizzle documentation for the sql tagged template literal used for raw SQL expressions, JSON path queries, and custom aggregations
- drizzle-zod — Official Drizzle documentation for drizzle-zod: createSelectSchema, createInsertSchema, and extending generated schemas with custom Zod types
- PostgreSQL JSONB docs — PostgreSQL official documentation for the json and jsonb data types, operators (->, ->>, @>), functions, and GIN index usage
- Drizzle indexes — Official Drizzle documentation for defining indexes including expression indexes and the using() method for GIN and GiST index types