JSON Columns in MikroORM: JsonType, Queries, and TypeScript
Last updated:
MikroORM maps JSON database columns using either the built-in JsonType or the 'json'/'jsonb' native type string in the @Property() decorator. Declare @Property({ type: JsonType }) on an entity property to store any serializable JavaScript value as a JSON string — MikroORM calls JSON.stringify on write and JSON.parse on read automatically. For PostgreSQL jsonb with binary storage and GIN index support, use @Property({ type: 'jsonb' }). Unlike raw SQL ORMs, MikroORM tracks property changes through its Unit of Work — you can mutate a JSON property in-place and call em.flush(), and MikroORM will detect the change and generate an UPDATE. This works because MikroORM snapshots entity state on load and diffs it on flush. For deeply nested JSON, MikroORM's embedded entity feature provides an alternative: define a separate class with @Embedded() and get full validation, filtering, and nested queries on each subfield. This guide covers JsonType, embedded entities, JSON path queries with em.find(), TypeScript types, custom JSON types with Type<T>, and GIN index migrations.
Defining JSON Properties with JsonType and 'jsonb'
Bottom line: use @Property({ type: JsonType }) for cross-database JSON and @Property({ type: 'jsonb' }) for PostgreSQL binary JSON. Set the TypeScript type directly on the class property — compile-time safety is independent of the database type strategy.
JsonType is a MikroORM built-in (imported from "@mikro-orm/core") that wraps serialization logic: it calls JSON.stringify before writing to the database and JSON.parse after reading. The 'jsonb' native type string passes the type directly to the PostgreSQL driver without MikroORM's serialization wrapper — the pg driver handles JSON natively, and PostgreSQL stores it in binary form. For MySQL or MariaDB, use type: 'json' instead. The TypeScript type of the property (e.g. metadata: ProductMetadata) is declared directly on the class field — it works regardless of which database type strategy you pick and gives compile-time shape checking across your entire codebase. For nullable columns, add nullable: true; for a default value, use default: {} in the decorator or assign in the class body. There is no functional difference between the two strategies for 99% of use cases — choose 'jsonb' on PostgreSQL when you need GIN indexing or the @> containment operator, and JsonType for portability across databases.
import {
Entity, PrimaryKey, Property, JsonType
} from "@mikro-orm/core"
// --- TypeScript interfaces for the JSON columns ---
interface ProductMetadata {
plan: "free" | "pro" | "enterprise"
features: string[]
trialEndsAt?: string
}
interface OrderLineItem {
productId: number
quantity: number
price: number
}
// --- Cross-database: JsonType handles serialization ---
@Entity()
class Product {
@PrimaryKey()
id!: number
@Property()
name!: string
// JsonType: MikroORM calls JSON.stringify / JSON.parse
@Property({ type: JsonType })
metadata: ProductMetadata = { plan: "free", features: [] }
// Nullable JSON with no default
@Property({ type: JsonType, nullable: true })
config: Record<string, unknown> | null = null
}
// --- PostgreSQL: 'jsonb' native type (binary storage, GIN-indexable) ---
@Entity()
class Order {
@PrimaryKey()
id!: number
// 'jsonb' passes the type to the pg driver directly
@Property({ type: 'jsonb' })
lineItems: OrderLineItem[] = []
// jsonb with a database-level default
@Property({ type: 'jsonb', default: '{}' })
shipping: Record<string, unknown> = {}
}
// --- MySQL / MariaDB: use 'json' instead of 'jsonb' ---
@Entity()
class Event {
@PrimaryKey()
id!: number
@Property({ type: 'json' })
payload: Record<string, unknown> = {}
}Unit of Work JSON Change Detection
Bottom line: MikroORM's Unit of Work (UoW) snapshots entity state on load. Mutate a JSON property in-place, call em.flush(), and MikroORM generates the correct UPDATE — no markModified(), no manual diffing.
When an entity is loaded via em.findOne() or em.find(), MikroORM stores a deep clone of its state as the "original entity data." On em.flush(), it performs a deep diff between the current state and the snapshot, identifies which properties changed, and generates minimal UPDATE SQL covering only the changed columns. For JSON properties, this means mutating a nested field — product.metadata.plan = 'pro' — is detected automatically. The generated SQL is UPDATE products SET metadata = '{"plan":"pro","features":[]}' WHERE id = 1. This contrasts with Sequelize (requires product.changed('metadata', true)) and Mongoose (requires product.markModified('metadata')) for in-place mutations to be detected. You can inspect the snapshot at any time: em.getUnitOfWork().getOriginalEntityData(product) returns the object MikroORM will diff against. For JSON objects larger than 10 KB, the deep comparison on every flush has measurable CPU overhead — in production, profile with realistic payloads and consider splitting large JSON across multiple narrower columns.
import { MikroORM, EntityManager } from "@mikro-orm/core"
import { Product } from "./entities/product.entity"
async function example(em: EntityManager) {
// --- Load: MikroORM snapshots the entity state ---
const product = await em.findOneOrFail(Product, { id: 1 })
// product.metadata = { plan: "free", features: ["basic"] }
// --- In-place mutation: no markModified() needed ---
product.metadata.plan = "pro"
product.metadata.features.push("analytics")
// --- Inspect the snapshot before flush ---
const snapshot = em.getUnitOfWork().getOriginalEntityData(product)
console.log(snapshot?.metadata)
// → { plan: "free", features: ["basic"] } ← the pre-mutation state
// --- flush(): MikroORM diffs, generates UPDATE ---
await em.flush()
// Generated SQL:
// UPDATE "products"
// SET "metadata" = '{"plan":"pro","features":["basic","analytics"]}'
// WHERE "id" = 1
// --- Reassigning the entire property also works ---
product.metadata = {
plan: "enterprise",
features: ["analytics", "api-access", "sso"],
trialEndsAt: undefined,
}
await em.flush()
// UPDATE "products" SET "metadata" = '{"plan":"enterprise","features":[...]}' WHERE "id" = 1
// --- Creating a new entity: persist then flush ---
const newProduct = em.create(Product, {
name: "Widget Pro",
metadata: { plan: "pro", features: ["api-access"] },
})
em.persist(newProduct)
await em.flush()
// INSERT INTO "products" ("name", "metadata") VALUES ('Widget Pro', '{"plan":"pro",...}')
}JSON Path Queries with em.find() and QueryBuilder
Bottom line: MikroORM has no built-in JSON path operator helpers in em.find(). Use the raw() helper (v6+) for simple path expressions, or the QueryBuilder for full PostgreSQL JSON operator syntax.
MikroORM v6 introduced a raw() helper that lets you embed raw SQL fragments inside the structured em.find() where clause. For older versions or complex expressions, use em.createQueryBuilder() with a .where() raw string. PostgreSQL provides 2 text-extraction operators: -> extracts a JSON field as a JSON value, and ->> extracts it as text (use this for string comparisons in WHERE clauses). The @> containment operator checks whether the left jsonb value contains the right — it uses a GIN index when one exists, reducing query time from O(n) to O(log n). For MySQL, use JSON_EXTRACT(col, '$.key') or the col->'$.key' shorthand. The table below shows the 4 most common patterns:
| Goal | PostgreSQL operator | MikroORM syntax |
|---|---|---|
| Extract field as text | metadata->>'plan' | raw("metadata->>?", ["plan"]) |
| Extract as JSON value | metadata-> 'plan' | raw("metadata->?", ["plan"]) |
| Containment (jsonb) | metadata @> '{"active":true}' | QueryBuilder .where("... @> ?::jsonb") |
| Key existence | metadata ? 'tags' | QueryBuilder .where("... \\? ?") |
import { EntityManager, raw } from "@mikro-orm/core"
import { Product } from "./entities/product.entity"
import { Order } from "./entities/order.entity"
async function queries(em: EntityManager) {
// --- raw() helper (MikroORM v6+): text extraction ---
const proProducts = await em.find(Product, {
[raw("metadata->>?", ["plan"])]: "pro",
})
// SELECT * FROM "products" WHERE metadata->>'plan' = 'pro'
// --- raw() with a nested path ---
const withAnalytics = await em.find(Product, {
[raw("metadata->'features' ? ?", ["analytics"])]: true,
})
// --- QueryBuilder: full PostgreSQL JSON operator syntax ---
const qb = em.createQueryBuilder(Product, "p")
const enterpriseProducts = await qb
.where("p.metadata->>'plan' = ?", ["enterprise"])
.getResultList()
// --- QueryBuilder: @> containment (uses GIN index) ---
const activeOrders = await em
.createQueryBuilder(Order, "o")
.where("o.shipping @> ?::jsonb", [JSON.stringify({ express: true })])
.getResultList()
// --- QueryBuilder: nested path ---
const highValue = await em
.createQueryBuilder(Order, "o")
.where("(o.shipping->>'estimatedDays')::int < ?", [3])
.getResultList()
// --- QueryBuilder: MySQL JSON_EXTRACT ---
// const redProducts = await em
// .createQueryBuilder(Product, "p")
// .where("JSON_EXTRACT(p.metadata, '$.color') = ?", ["red"])
// .getResultList()
// --- Select a JSON field as a computed column ---
const plans = await em
.createQueryBuilder(Product, "p")
.select(["p.id", "p.name", raw("p.metadata->>'plan' as plan")])
.execute()
// plans[0].plan → "pro" (string from PostgreSQL ->> operator)
}Embedded Entities as Typed JSON Alternative
Bottom line: @Embedded() stores a nested class as a JSON object in a single column and lets you query individual fields with em.find(User, { 'address.city': 'Portland' }) — no raw SQL needed.
Define the embedded class with @Embeddable() and individual @Property() decorators on each field. On the parent entity, use @Embedded(() => Address) address: Address. MikroORM stores the embedded object as a single JSON column (e.g. { street: '...', city: '...', zip: '...' }) and generates JSON path SQL automatically for nested where clauses — you get typed, auto-generated path queries without writing a single raw SQL fragment. Each field on the embedded class is individually typed, validated (if you add @Property({ validation: ... })), and referable in queries. The prefix: false option flattens the embedded fields into separate columns on the parent table instead of storing as JSON — useful when you want to migrate from a JSON column to individual columns without changing application code. Compare to JsonType: embedded gives field-level queries and compile-time safety for fixed schemas; JsonType gives full flexibility for variable or unknown structures. 3 properties per embedded class is the minimum that justifies the overhead of the extra class definition; for 1–2 fields, JsonType with a TypeScript interface is simpler.
import {
Entity, PrimaryKey, Property,
Embeddable, Embedded
} from "@mikro-orm/core"
// --- Define the embedded class ---
@Embeddable()
class Address {
@Property()
street!: string
@Property()
city!: string
@Property()
zip!: string
@Property({ nullable: true })
country?: string
}
// --- Parent entity: Address stored as a jsonb column ---
@Entity()
class User {
@PrimaryKey()
id!: number
@Property()
name!: string
// Stored as a single jsonb column: address jsonb
@Embedded(() => Address)
address!: Address
}
// --- Usage: MikroORM generates JSON path SQL automatically ---
async function examples(em: EntityManager) {
// Create with embedded
const user = em.create(User, {
name: "Alice",
address: { street: "123 Main St", city: "Portland", zip: "97201" },
})
em.persist(user)
await em.flush()
// INSERT INTO "users" ("name", "address")
// VALUES ('Alice', '{"street":"123 Main St","city":"Portland","zip":"97201"}')
// Nested where clause — no raw SQL needed
const portlandUsers = await em.find(User, { "address.city": "Portland" })
// SELECT * FROM "users" WHERE address->>'city' = 'Portland'
// Partial update — Unit of Work detects the change
const alice = await em.findOneOrFail(User, { id: 1 })
alice.address.city = "Seattle"
await em.flush()
// UPDATE "users" SET "address" = '{"street":"123 Main St","city":"Seattle","zip":"97201"}' WHERE "id" = 1
}
// --- prefix: false → flatten into separate columns (no JSON) ---
@Entity()
class Profile {
@PrimaryKey()
id!: number
// prefix: false → individual columns: street TEXT, city TEXT, zip TEXT
@Embedded(() => Address, { prefix: false })
address!: Address
}
// ALTER TABLE "profiles" ADD COLUMN "street" varchar(255) NOT NULL;
// ALTER TABLE "profiles" ADD COLUMN "city" varchar(255) NOT NULL;
// ALTER TABLE "profiles" ADD COLUMN "zip" varchar(255) NOT NULL;Custom JSON Types with Type{T}
Bottom line: extend Type<T> from @mikro-orm/core to intercept every database read and write. Override convertToJSValue to add Zod validation on reads and catch schema drift before it reaches application code.
MikroORM's Type<T> is the base class for all column types including JsonType. You can extend it to add arbitrary transformation logic on both sides of the database boundary. The 2 methods you override are convertToJSValue(value: string): T (called after reading from the database) and convertToDatabaseValue(value: T): string (called before writing). Wrapping these with a Zod schema catches 3 categories of bugs: (1) schema drift between app versions when the JSON shape changes but old rows remain, (2) manual database edits that break the expected structure, and (3) data migration errors that write invalid JSON. When Zod throws a ZodError inside convertToJSValue, MikroORM propagates it as a runtime exception — you get a clear stack trace pointing to the exact field that failed. Register a custom type globally in the MikroORM config under the types key to avoid repeating new ValidatedJsonType(schema) everywhere.
import { Type, Platform, EntityProperty } from "@mikro-orm/core"
import { z, ZodSchema } from "zod"
// --- Generic validated JSON type ---
class ValidatedJsonType<T> extends Type<T, string> {
constructor(private readonly schema: ZodSchema<T>) {
super()
}
// Called after reading from the database
convertToJSValue(value: string | T | null): T {
if (value === null || value === undefined) return value as T
const parsed = typeof value === "string" ? JSON.parse(value) : value
// Throws ZodError if the shape doesn't match — caught immediately
return this.schema.parse(parsed)
}
// Called before writing to the database
convertToDatabaseValue(value: T): string {
// Validate on write too (catches bad inserts/updates)
const validated = this.schema.parse(value)
return JSON.stringify(validated)
}
getColumnType(_prop: EntityProperty, _platform: Platform): string {
return "jsonb"
}
}
// --- Define the Zod schema ---
const productMetadataSchema = z.object({
plan: z.enum(["free", "pro", "enterprise"]),
features: z.array(z.string()),
trialEndsAt: z.string().datetime().optional(),
})
type ProductMetadata = z.infer<typeof productMetadataSchema>
// --- Use the custom type on an entity property ---
@Entity()
class Product {
@PrimaryKey()
id!: number
@Property()
name!: string
// ValidatedJsonType validates every read and write
@Property({ type: new ValidatedJsonType(productMetadataSchema) })
metadata!: ProductMetadata
}
// --- Global registration in MikroORM config ---
// import { MikroORM } from "@mikro-orm/core"
// const orm = await MikroORM.init({
// entities: [Product],
// types: {
// productMetadata: new ValidatedJsonType(productMetadataSchema),
// },
// })
// --- Error caught on read ---
// If the database contains { plan: "legacy" } (not in the enum),
// convertToJSValue throws:
// ZodError: [{ code: "invalid_enum_value", path: ["plan"], ... }]Migrations and GIN Indexes
Bottom line: use mikro-orm migration:create to generate migration files from entity changes, then add GIN index SQL manually — the @Index() decorator does not support the USING GIN clause.
MikroORM's schema migration tool compares your entity definitions against the current database schema and generates ALTER TABLE and CREATE INDEX SQL to bring them in sync. Run 3 commands: mikro-orm schema:update --dump to preview, mikro-orm migration:create to write the migration file, and mikro-orm migration:up to apply it. For jsonb columns, the auto-generated migration adds the column with the correct type. For GIN indexes, MikroORM's @Index({ properties: ['metadata'] }) decorator generates a B-tree index — it does not support USING GIN. Add the GIN index in the generated migration file using this.addSql(). The jsonb_path_ops GIN operator class is a specialized variant: it indexes only the containment (@>) operator's data structures, making the index 3× smaller than the default GIN and 2–4× faster for @> queries. The trade-off: jsonb_path_ops does not support the ? key-existence operator. Choose jsonb_path_ops when 100% of your GIN queries use @>.
# --- Generate a migration from entity changes ---
npx mikro-orm migration:create --name add-products-metadata-jsonb
# --- Preview schema changes without writing a file ---
npx mikro-orm schema:update --dump
# --- Apply pending migrations ---
npx mikro-orm migration:up// --- Generated migration file (edited to add GIN index) ---
import { Migration } from "@mikro-orm/migrations"
export class Migration20260527_AddProductsMetadata extends Migration {
async up(): Promise<void> {
// Auto-generated: add the jsonb column
this.addSql(
'ALTER TABLE "products" ADD COLUMN "metadata" jsonb NOT NULL DEFAULT '{}'::jsonb'
)
// Manually added: default GIN index (supports @> and ? operators)
this.addSql(
'CREATE INDEX "products_metadata_gin" ON "products" USING GIN ("metadata")'
)
// Alternative: jsonb_path_ops GIN (3× smaller, 2–4× faster for @>, no ? support)
// this.addSql(
// 'CREATE INDEX "products_metadata_gin" ON "products" USING GIN ("metadata" jsonb_path_ops)'
// )
}
async down(): Promise<void> {
this.addSql('DROP INDEX IF EXISTS "products_metadata_gin"')
this.addSql('ALTER TABLE "products" DROP COLUMN "metadata"')
}
}
// --- Entity with @Index() decorator (generates B-tree, not GIN) ---
import { Entity, PrimaryKey, Property, Index } from "@mikro-orm/core"
// @Index on a jsonb column → generates:
// CREATE INDEX "products_metadata_idx" ON "products" ("metadata")
// This is a B-tree index — useful for equality on the full column, not path queries
@Entity()
@Index({ properties: ["metadata"] }) // B-tree only; add GIN manually in migration
class Product {
@PrimaryKey()
id!: number
@Property({ type: "jsonb" })
metadata: Record<string, unknown> = {}
}
// --- Verify the GIN index is in use ---
// Run EXPLAIN ANALYZE on a containment query:
// EXPLAIN ANALYZE SELECT * FROM products WHERE metadata @> '{"plan":"pro"}'::jsonb;
// Look for: "Index Scan using products_metadata_gin on products"
// Without GIN: "Seq Scan on products (cost=0.00..X rows=X)"
// With GIN: "Bitmap Heap Scan ... Bitmap Index Scan on products_metadata_gin"FAQ
How do I define a JSON column in MikroORM?
Use @Property({ type: JsonType }) for a cross-database JSON column, where JsonType is imported from "@mikro-orm/core". For PostgreSQL binary JSON with GIN index support, use @Property({ type: 'jsonb' }). For MySQL or MariaDB, use @Property({ type: 'json' }). Add nullable: true for optional columns, or default: {} for a database-level default. The TypeScript type is declared directly on the class property — it is independent of the database type strategy and gives compile-time safety without any additional configuration.
Does MikroORM detect in-place JSON mutations automatically?
Yes. MikroORM's Unit of Work snapshots entity state when the entity is loaded. On em.flush(), it diffs the current state against the snapshot and generates UPDATE SQL for changed properties. Mutating product.metadata.plan = 'pro' and calling em.flush() is enough — no markModified() call is needed. This differs from Sequelize and Mongoose, which both require explicit mutation signaling. Inspect the snapshot with em.getUnitOfWork().getOriginalEntityData(entity). For JSON objects larger than 10 KB, the deep comparison on every flush has measurable overhead — profile in production.
How do I query a nested JSON field in MikroORM?
Use the raw() helper (MikroORM v6+) inside em.find(): em.find(Product, { [raw("metadata->>?", ["plan"])]: "pro" }). For complex expressions, use em.createQueryBuilder(Product).where("p.metadata->>?'plan' = ?", ['pro']). For PostgreSQL jsonb containment: .where("p.metadata @> ?::jsonb", [JSON.stringify({ active: true })]). Embedded entities provide a typed alternative: em.find(User, { 'address.city': 'Portland' }) generates the JSON path query automatically.
What are MikroORM embedded entities and when should I use them instead of JsonType?
Embedded entities use @Embeddable() on a class and @Embedded() on the parent entity property. MikroORM stores the embedded object as a JSON column but treats each field as individually queryable — em.find(User, { 'address.city': 'Portland' }) generates correct JSON path SQL automatically. Use embedded entities for fixed, structured JSON with 3 or more known fields. Use JsonType for variable or unknown shapes, arrays of heterogeneous objects, or when you want flexibility without defining an extra class. The prefix: false option migrates the embedded object to individual columns without changing application code.
How do I add Zod validation to MikroORM JSON reads?
Extend Type<T> from @mikro-orm/core and override convertToJSValue(value) to call JSON.parse then schema.parse(result). Override convertToDatabaseValue(value) for write-time validation. Register per property: @Property({ type: new ValidatedJsonType(ProductMetadataSchema) }). This catches schema drift — if old database rows have a shape that no longer matches the current schema, Zod throws a ZodError on read rather than silently returning a malformed object.
How do I create a GIN index on a jsonb column in MikroORM?
The @Index() decorator generates a B-tree index and does not support USING GIN. Add GIN indexes manually in a migration file: this.addSql('CREATE INDEX "name" ON "table" USING GIN ("column")'). For containment-only workloads, use jsonb_path_ops: add USING GIN ("column" jsonb_path_ops) — this variant is 3× smaller and 2–4× faster for @> queries, but does not support the ? key-existence operator. Verify the index is used with EXPLAIN ANALYZE on a containment query.
Definitions
JsonType- A built-in MikroORM column type (from
@mikro-orm/core) that automatically callsJSON.stringifybefore writing to the database andJSON.parseafter reading. Works across PostgreSQL, MySQL, SQLite, and other supported databases. - Unit of Work
- MikroORM's transaction management pattern. The entity manager maintains an in-memory snapshot of every loaded entity. On
em.flush(), it diffs current state against the snapshots and generates the minimal set of SQL statements needed to synchronize the database. @Embedded()- A MikroORM decorator that marks a property on a parent entity as an embedded object. MikroORM stores the embedded class as a JSON value in a single column (or as individual columns with
prefix: false) and enables field-level queries on nested properties. @Embeddable()- A MikroORM decorator applied to a class to mark it as an embeddable type. The class defines the structure of the embedded JSON object, with each
@Property()field individually typed and queryable via the parent entity's where clauses. Type<T>- The base class for all MikroORM column type implementations (from
@mikro-orm/core). Extend it to define custom serialization, deserialization, and validation logic for any column type. Key methods:convertToJSValue(database → JS),convertToDatabaseValue(JS → database), andgetColumnType(DDL type string). - QueryBuilder
- MikroORM's fluent query construction API, accessed via
em.createQueryBuilder(Entity, alias). Supports raw SQL fragments in.where(),.select(),.orderBy(), and.having()clauses, enabling full access to PostgreSQL JSON operators and functions without losing the ORM's entity mapping.
Using Drizzle, TypeORM, or Sequelize instead?
The same jsonb column patterns — path queries, GIN indexes, TypeScript types — apply across all major Node.js ORMs. See the equivalent guides for JSON in Drizzle ORM, JSON in TypeORM, JSON in Sequelize, and JSON in Knex.
Open JSON Validator on JsonicFurther reading and primary sources
- MikroORM Property types — Official MikroORM documentation on custom types, JsonType, and how to extend Type<T> for custom serialization and validation logic
- MikroORM Unit of Work — Official MikroORM documentation explaining the Unit of Work pattern, entity snapshots, identity map, and how flush() generates minimal SQL
- MikroORM Embedded Entities — Official MikroORM documentation for @Embeddable() and @Embedded() decorators, including JSON column storage, prefix options, and nested where clauses
- MikroORM Migrations — Official MikroORM documentation for the schema migration tool: generating, applying, and customizing migrations with raw SQL via this.addSql()
- PostgreSQL JSONB docs — PostgreSQL official documentation for json and jsonb data types, operators (->, ->>, @>), functions, GIN indexing, and jsonb_path_ops operator class