JSON Fields in Mongoose: Mixed Type, Schema Design, and Queries

Last updated:

Mongoose stores flexible JSON data in MongoDB using Schema.Types.Mixed — a field that bypasses Mongoose's schema validation and accepts any JavaScript value. Define a Mixed field with metadata: Schema.Types.Mixed in your schema, and Mongoose passes the value to MongoDB as-is without any type coercion or validation. The critical limitation: when you mutate a Mixed field in-place (e.g., doc.metadata.plan = 'pro'), Mongoose cannot detect the change and will skip the field in the save() call. You must call doc.markModified('metadata') before save() to force Mongoose to include the field in the MongoDB $set operation. For structured JSON, prefer nested subdocument schemas or the Mixed type with explicit TypeScript generics — both give you type safety and Mongoose validation. This guide covers Mixed vs subdocument schemas, markModified() pattern, dot-notation queries on JSON fields, index strategies, and TypeScript type safety with generics.

Defining JSON Fields: Mixed vs Subdocument Schemas

Bottom line: use Schema.Types.Mixed for truly arbitrary JSON where the shape varies per document, and nested schema objects for JSON with a known, fixed structure. Mixed accepts any value and skips all Mongoose validation; nested schemas validate each subfield on every save.

Schema.Types.Mixed has 3 equivalent shorthands: metadata: Schema.Types.Mixed, metadata: mongoose.Schema.Types.Mixed, and metadata: {} (empty object literal). All three tell Mongoose to accept any BSON-compatible value. The nested subdocument approach defines the JSON shape inline: { plan: String, features: [String], createdAt: Date }. Mongoose then validates plan as a string, features as a string array, and createdAt as a date on every save() — type casting and required checks included. Choose Mixed for user-defined configs, plugin data, or any field whose keys cannot be enumerated at schema design time. Choose a nested schema when the JSON has a known shape: user preferences, address objects, payment metadata. Mongoose subdocument schemas also support virtuals, custom validators, and their own toJSON() transforms — none of which are available on Mixed fields.

import mongoose, { Schema } from 'mongoose'

// --- Schema.Types.Mixed: accepts any JS value, no validation ---
const userSchemaMixed = new mongoose.Schema({
  email: { type: String, required: true, unique: true },

  // Three equivalent ways to define a Mixed field:
  metadata: Schema.Types.Mixed,           // explicit
  config:   mongoose.Schema.Types.Mixed,  // fully qualified
  extras:   {},                           // shorthand empty object
})

// --- Nested subdocument schema: validated on every save() ---
const userSchemaStructured = new mongoose.Schema({
  email: { type: String, required: true, unique: true },

  metadata: {
    plan:      { type: String, enum: ['free', 'pro', 'enterprise'], default: 'free' },
    features:  { type: [String], default: [] },
    createdAt: { type: Date, default: Date.now },
    score:     { type: Number, min: 0, max: 100 },
  },
})

// --- Mixed with a nested schema type: runtime Mixed, TS type-safe ---
interface UserMetadata {
  plan: 'free' | 'pro' | 'enterprise'
  features: string[]
  createdAt: Date
}

interface IUser {
  email: string
  metadata: UserMetadata
}

const userSchemaTyped = new mongoose.Schema<IUser>({
  email:    { type: String, required: true },
  metadata: Schema.Types.Mixed,  // runtime: Mixed; TypeScript: UserMetadata
})

const User = mongoose.model<IUser>('User', userSchemaTyped)

// TypeScript knows doc.metadata is UserMetadata:
const doc = await User.findOne({})
if (doc) {
  console.log(doc.metadata.plan)  // typed as 'free' | 'pro' | 'enterprise'
}

The markModified() Pattern for In-Place Updates

Bottom line: in-place mutation of a Mixed field (doc.metadata.key = value) silently skips the field on save(). Call doc.markModified('metadata') after any in-place mutation, or use doc.set() / atomic $set updates to avoid the issue entirely.

Mongoose tracks changes with an internal __dirty set populated by property setter proxies. When you replace a Mixed field entirely — doc.metadata = { plan: 'pro' } — Mongoose detects the assignment through the proxy and marks the field dirty. When you mutate in-place — doc.metadata.plan = 'pro' — the proxy on the top-level metadata property never fires, so Mongoose's dirty tracker has no record of the change. The save() call then generates a $set that omits metadata, leaving the old value in MongoDB. The fix is always doc.markModified('metadata') after mutation. For deeply nested paths, you can mark only the changed path: doc.markModified('metadata.settings'). Two alternatives avoidmarkModified() entirely: doc.set('metadata', { ...doc.metadata, plan: 'pro' }) triggers the proxy via full assignment, and Model.updateOne({ _id }, { $set: { 'metadata.plan': 'pro' } }) bypasses Mongoose document tracking altogether with a direct MongoDB write.

import { User } from './models'

// ── THE BUG: in-place mutation without markModified ──
async function bugExample(userId: string) {
  const doc = await User.findById(userId)
  if (!doc) return

  doc.metadata.plan = 'pro'   // mutation — proxy does NOT fire
  await doc.save()             // metadata is NOT included in $set — change lost!
}

// ── THE FIX: markModified() before save() ──
async function fixMarkModified(userId: string) {
  const doc = await User.findById(userId)
  if (!doc) return

  doc.metadata.plan = 'pro'
  doc.markModified('metadata')   // tell Mongoose to include metadata in $set
  await doc.save()               // now the change is persisted
}

// ── ALTERNATIVE 1: doc.set() triggers the proxy via full assignment ──
async function fixDocSet(userId: string) {
  const doc = await User.findById(userId)
  if (!doc) return

  // Spread preserves existing fields, triggers the setter proxy
  doc.set('metadata', { ...doc.metadata, plan: 'pro' })
  await doc.save()   // no markModified() needed
}

// ── ALTERNATIVE 2: $set — no document round-trip, no markModified needed ──
async function fixAtomicSet(userId: string) {
  await User.updateOne(
    { _id: userId },
    { $set: { 'metadata.plan': 'pro' } }   // MongoDB updates the subfield directly
  )
  // No findById + save overhead — fastest option for single-field updates
}

// ── Marking a deep nested path ──
async function fixDeepPath(userId: string) {
  const doc = await User.findById(userId)
  if (!doc) return

  doc.metadata.settings = { theme: 'dark', timezone: 'UTC' }
  doc.markModified('metadata.settings')   // only marks the settings subpath dirty
  await doc.save()
}

Querying JSON Fields with Dot Notation

Bottom line: Mongoose passes dot notation query filters directly to MongoDB. Use 'metadata.plan' as a query key to match on nested JSON fields. The same syntax works for $elemMatch, range queries, $exists, $type, and projections.

Dot notation queries work identically on Mixed fields and nested subdocument schemas — Mongoose adds no extra query translation layer. Model.find({ 'metadata.plan': 'pro' }) generates db.collection.find({ 'metadata.plan': 'pro' }) in MongoDB. For array elements inside a JSON field, $elemMatch matches documents where at least 1 array element satisfies all conditions in a single element — important when matching multiple fields on the same array element. Range queries use the standard MongoDB comparison operators: $gte, $lte, $gt, $lt. The $exists operator checks key presence without caring about value. $type filters by BSON type code — useful when a Mixed field may hold values of different types across documents. Projection on a subfield returns only that path:.select('metadata.plan -_id') returns exactly 1 field per document with no _id.

import { User } from './models'

// --- Basic dot notation equality match ---
const proUsers = await User.find({ 'metadata.plan': 'pro' })

// --- Range query on a numeric JSON subfield ---
const highScorers = await User.find({ 'metadata.score': { $gte: 80 } })

// --- $exists: documents that have the key (regardless of value) ---
const withFeatureFlag = await User.find({ 'metadata.featureFlag': { $exists: true } })

// --- $type: filter by BSON type (2 = string, 16 = int32, 8 = boolean) ---
const stringPlans = await User.find({ 'metadata.plan': { $type: 'string' } })

// --- $elemMatch: match on multiple fields of the same array element ---
// metadata.tags is an array of objects: [{ name: string, active: boolean }]
const activeBillingTag = await User.find({
  'metadata.tags': { $elemMatch: { name: 'billing', active: true } },
})

// --- Simple array value match (no $elemMatch needed for single condition) ---
const hasApiAccess = await User.find({ 'metadata.features': 'api-access' })

// --- Projection: return only the plan subfield ---
const plans = await User
  .find({ 'metadata.plan': { $exists: true } })
  .select('email metadata.plan -_id')
// Returns: [{ email: '...', metadata: { plan: 'pro' } }]

// --- Combine with other Mongoose query methods ---
const recentPro = await User
  .find({ 'metadata.plan': 'pro' })
  .sort({ createdAt: -1 })
  .limit(20)
  .lean()   // plain JS objects, 2-3x faster for read-only use

// --- $or across JSON subfields ---
const proOrEnterprise = await User.find({
  $or: [
    { 'metadata.plan': 'pro' },
    { 'metadata.plan': 'enterprise' },
  ],
})

Indexing JSON Subfields

Bottom line: use schema.index({ 'metadata.plan': 1 }) to create a B-tree index on a JSON subfield path. For nested paths inside Mixed fields, schema.index() is the only reliable method — the inline index: true shorthand only works on top-level schema paths.

MongoDB indexes JSON subfields using the same B-tree structure as top-level fields — the dot notation path is stored as the index key. A single-field index on 'metadata.plan' supports exact equality queries, range scans, and sort operations on that path. Compound indexes combine a JSON subfield with other fields: schema.index({ 'metadata.plan': 1, createdAt: -1 }) supports queries that filter on plan and sort by date — MongoDB can satisfy both conditions from a single index scan. For array fields inside JSON (e.g., metadata.features is a string array), MongoDB automatically creates a multikey index when the indexed field contains an array — no special syntax needed, but a compound index cannot contain 2 or more multikey fields simultaneously. Unlike PostgreSQL's GIN index, MongoDB has no single index structure for arbitrary key existence across a document — each query path needs its own index. Run db.users.explain('executionStats').find({ 'metadata.plan': 'pro' }) in the MongoDB shell and check winningPlan.inputStage.stage: an IXSCAN means the index is used; a COLLSCAN means a full scan is happening.

import mongoose, { Schema } from 'mongoose'

const userSchema = new mongoose.Schema({
  email:    { type: String, required: true },
  metadata: Schema.Types.Mixed,
  createdAt: { type: Date, default: Date.now },
})

// --- Single-field B-tree index on a JSON subfield ---
userSchema.index({ 'metadata.plan': 1 })

// --- Compound index: JSON subfield + top-level date field ---
// Supports: User.find({ 'metadata.plan': 'pro' }).sort({ createdAt: -1 })
userSchema.index({ 'metadata.plan': 1, createdAt: -1 })

// --- Index on an array subfield (becomes a multikey index automatically) ---
// metadata.features is string[], e.g. ['api-access', 'sso']
// Supports: User.find({ 'metadata.features': 'api-access' })
userSchema.index({ 'metadata.features': 1 })

// --- Sparse index: only index documents where the path exists ---
// Useful for optional JSON subfields — skips nulls, saves index space
userSchema.index({ 'metadata.featureFlag': 1 }, { sparse: true })

// --- Unique index on a JSON subfield ---
userSchema.index({ 'metadata.externalId': 1 }, { unique: true, sparse: true })

// --- Verify index usage with explain() ---
// Run in MongoDB shell (not Mongoose):
// db.users.explain('executionStats').find({ 'metadata.plan': 'pro' })
// Look for: winningPlan.inputStage.stage === 'IXSCAN'
// Also check: totalDocsExamined should equal nReturned for full index coverage

// --- Wrong: inline index: true on nested Mixed field does NOT create the index ---
const wrongSchema = new mongoose.Schema({
  metadata: {
    // This index: true is silently ignored for nested paths in a Mixed field
    plan: { type: String, index: true },  // ← does nothing when parent is Mixed
  },
})
// Correct: use schema.index() explicitly
wrongSchema.index({ 'metadata.plan': 1 })

TypeScript Type Safety with Mongoose Generics

Bottom line: Mongoose 6+ accepts generic type parameters on Schema and model. Define an interface for the document shape, pass it as a generic, and TypeScript enforces the type at the application layer — even when the runtime field type is Mixed.

The generic flow: define interface IUser { metadata: UserMetadata }, create new mongoose.Schema<IUser>({...}), and call mongoose.model<IUser>('User', schema). After this, doc.metadata is typed as UserMetadata at compile time. The runtime Mongoose type is still Mixed — MongoDB will accept any value — so the type is a compile-time contract, not a runtime guarantee. For runtime enforcement, use custom Mongoose validators on the Mixed field or integrate mongoose-zod which generates Mongoose schemas from Zod definitions. The return type of findOne() is HydratedDocument<IUser> | nullHydratedDocument adds Mongoose document methods (save(), markModified(), etc.) on top of the plain interface. With.lean(), the return type narrows to IUser | null (plain object, no Mongoose methods). Mongoose 6 also supports a second generic for query helpers and a third for instance methods, giving you full type coverage across the model.

import mongoose, { Schema, HydratedDocument, Model } from 'mongoose'

// --- TypeScript interfaces for the document and JSON subfield ---
interface UserMetadata {
  plan: 'free' | 'pro' | 'enterprise'
  features: string[]
  score: number
  createdAt: Date
}

interface IUser {
  email: string
  metadata: UserMetadata
  createdAt: Date
}

// --- Schema<IUser>: TypeScript enforces shape on schema definition ---
const userSchema = new Schema<IUser>({
  email:     { type: String, required: true, unique: true },
  metadata:  Schema.Types.Mixed,   // runtime: Mixed; TypeScript: UserMetadata
  createdAt: { type: Date, default: Date.now },
})

userSchema.index({ 'metadata.plan': 1 })

// --- model<IUser>: all query results typed as HydratedDocument<IUser> ---
const User: Model<IUser> = mongoose.model<IUser>('User', userSchema)

// --- findOne() returns HydratedDocument<IUser> | null ---
async function getUserPlan(userId: string): Promise<string | null> {
  const doc: HydratedDocument<IUser> | null = await User.findOne({ _id: userId })
  if (!doc) return null

  // TypeScript knows: doc.metadata.plan is 'free' | 'pro' | 'enterprise'
  return doc.metadata.plan
}

// --- lean() returns IUser | null (plain object, no Mongoose methods) ---
async function getUserLean(userId: string): Promise<IUser | null> {
  return User.findOne({ _id: userId }).lean()
  // 2-3x faster than full document — no markModified(), no save(), no toJSON()
}

// --- Insert is type-checked at compile time ---
async function createUser(email: string) {
  const doc = new User({
    email,
    metadata: {
      plan: 'free',
      features: [],
      score: 0,
      createdAt: new Date(),
    },
    // TypeScript error if metadata is missing required fields or has wrong types
  })
  await doc.save()
  return doc
}

// --- Runtime validator on the Mixed field for extra safety ---
userSchema.path('metadata').validate(function(value: unknown) {
  if (!value || typeof value !== 'object') return false
  const v = value as Record<string, unknown>
  return ['free', 'pro', 'enterprise'].includes(v.plan as string)
}, 'metadata.plan must be free, pro, or enterprise')

toJSON() and JSON Serialization

Bottom line: configure the toJSON schema option with a transform function to control what Mongoose documents look like when serialized. Use lean() for read-only queries — it is 2–3× faster and bypasses toJSON() entirely.

Mongoose documents have a toJSON() method called automatically by JSON.stringify() and res.json() in Express or Fastify. The transform function receives 2 arguments: the original Mongoose document (doc) and the plain object being serialized (ret). Mutations to ret affect only the serialized output — the stored document is unchanged. Common uses: delete ret.__v to strip the version key, map ret._id to ret.id for REST-friendly responses, or delete a Mixed field like ret.internalMeta to prevent it from appearing in API responses. Subdocument schemas call their own toJSON() transforms recursively. When virtuals: true is set, virtual properties (computed fields not stored in MongoDB) are included in the output. For read-only query paths — list endpoints, analytics, search — .lean() is the right tool: it skips document instantiation, proxy setup, and toJSON() processing, returning plain objects at 2–3× the throughput of hydrated documents.

import mongoose, { Schema } from 'mongoose'

interface IUser {
  email: string
  metadata: Record<string, unknown>
  internalMeta: Record<string, unknown>
  createdAt: Date
}

const userSchema = new Schema<IUser>(
  {
    email:        { type: String, required: true },
    metadata:     Schema.Types.Mixed,
    internalMeta: Schema.Types.Mixed,   // internal field, not for API responses
    createdAt:    { type: Date, default: Date.now },
  },
  {
    // toJSON runs whenever JSON.stringify(doc) or res.json(doc) is called
    toJSON: {
      virtuals: true,
      transform: (_doc, ret) => {
        // Map _id to id for REST convention
        ret.id = ret._id
        delete ret._id

        // Strip Mongoose version key
        delete ret.__v

        // Strip internal Mixed field from API responses
        delete ret.internalMeta

        return ret
      },
    },
  }
)

// --- Virtual: computed field included in toJSON output ---
userSchema.virtual('planLabel').get(function(this: IUser) {
  const plan = (this.metadata as { plan?: string })?.plan
  return plan === 'enterprise' ? 'Enterprise' : plan === 'pro' ? 'Pro' : 'Free'
})

const User = mongoose.model<IUser>('User', userSchema)

// --- Full document: toJSON() runs, transform applies ---
const doc = await User.findOne({ email: 'alice@example.com' })
// res.json(doc) → { id: '...', email: '...', metadata: {...}, planLabel: 'Pro' }
// internalMeta and __v are stripped; _id is mapped to id

// --- lean(): plain JS object, no toJSON() transform, 2-3x faster ---
const docs = await User.find({ 'metadata.plan': 'pro' }).lean()
// docs[0] is a plain object: { _id: ..., email: '...', metadata: {...}, __v: 0 }
// No transform, no virtuals — fastest option for read-only list queries

// --- Manually call toJSON() on a hydrated document ---
const user = await User.findOne({})
if (user) {
  const plain = user.toJSON()   // same output as JSON.stringify(user)
  console.log(plain.id)         // mapped from _id
  console.log(plain.planLabel)  // virtual included
}

FAQ

How do I store JSON in Mongoose?

Use Schema.Types.Mixed (or the shorthand {}) for arbitrary JSON with no fixed structure, or define a nested object in the schema for structured JSON. Mixed accepts any JavaScript value without validation; nested schemas validate each subfield on every save(). The MongoDB Node.js driver serializes JavaScript objects to BSON automatically — no JSON.stringify needed. Choose Mixed for user-defined configs or plugin data; choose a nested schema when the JSON shape is known and you want Mongoose validation.

Why does my Mongoose Mixed field not save after in-place mutation?

Mongoose's dirty tracker only fires when you reassign a top-level property. Mutating in-place — doc.metadata.plan = 'pro' — doesn't trigger the setter proxy, so save() silently omits the field. Fix: call doc.markModified('metadata') after any in-place mutation, then call save(). Alternatively, use doc.set('metadata', { ...doc.metadata, plan: 'pro' }) to trigger the proxy, or bypass Mongoose entirely with Model.updateOne({ _id }, { $set: { 'metadata.plan': 'pro' } }).

How do I query a nested JSON field in Mongoose?

Use dot notation as the query key: Model.find({ 'metadata.plan': 'pro' }). Mongoose passes this directly to MongoDB. For array elements: use $elemMatch when matching multiple conditions on the same element. For ranges: { 'metadata.score': { $gte: 80 } }. For key presence: { 'metadata.featureFlag': { $exists: true } }. For type filtering: $type: 'string'. For projections: .select('metadata.plan'). All queried paths need indexes to avoid collection scans.

How do I index a JSON subfield in Mongoose?

Use schema.index({ 'metadata.plan': 1 }) with dot notation. The inline index: true shorthand is silently ignored for nested paths inside Mixed fields — always use schema.index() explicitly for JSON subfields. Add { sparse: true } as the second argument to skip documents where the path doesn't exist. MongoDB automatically creates multikey indexes when the indexed path is an array — no extra configuration needed. Run explain('executionStats') in the MongoDB shell and check for IXSCAN (not COLLSCAN) to confirm the index is used.

How do I add TypeScript types to a Mongoose Mixed field?

Define an interface for the document shape and pass it as a generic: new mongoose.Schema<IUser>({...}) and mongoose.model<IUser>('User', schema). After this, doc.metadata is typed as UserMetadata at compile time. The runtime Mongoose type remains Mixed — MongoDB still accepts any value. Use HydratedDocument<IUser> for hydrated documents and IUser directly for .lean() results. For runtime enforcement, add a custom Mongoose validator or use mongoose-zod.

How do I control JSON output from Mongoose documents?

Configure the toJSON schema option with a transform function. The transform receives the Mongoose document and the plain object (ret): delete fields from ret to strip them, rename _id to id, or add computed values. Set virtuals: true to include virtual properties. Subdocument schemas call their own transforms recursively. For read-only queries, use .lean() which returns plain objects and is 2–3× faster than hydrated documents — toJSON() does not run on lean results.

Definitions

Schema.Types.Mixed
A Mongoose schema type that accepts any BSON-compatible JavaScript value without validation or type coercion. Shorthand forms are {} (empty object literal) and mongoose.Schema.Types.Mixed.
markModified()
A Mongoose document method that manually flags a path as dirty, forcing it to be included in the next save() call's $set operation. Required after any in-place mutation of a Mixed field.
dot notation query
A MongoDB query syntax using period-separated field names to address nested document paths, e.g. 'metadata.plan'. Works on Mixed fields and nested subdocument schemas. Mongoose passes dot notation filters directly to the MongoDB driver without transformation.
multikey index
A MongoDB index automatically created when the indexed field contains an array. Each array element gets its own index entry. A compound index cannot include 2 or more multikey fields simultaneously.
toJSON transform
A function configured in Mongoose's toJSON schema option that modifies the plain object returned by toJSON() and JSON.stringify(). Runs on every serialization; does not affect the stored document.
lean()
A Mongoose query modifier that returns plain JavaScript objects instead of Mongoose document instances. Bypasses document instantiation, proxy setup, and toJSON() processing, resulting in 2–3× faster query throughput. No save(), markModified(), or virtual access is available on lean results.

Working with MongoDB or other ORMs?

The JSON field patterns here apply across the MongoDB ecosystem. See the related guides for raw MongoDB JSON queries, JSON in TypeORM, and JSON in Drizzle ORM. For a broader overview of storing JSON across all database types, see JSON database queries guide.

Open JSON Validator

Further reading and primary sources

  • Mongoose SchemaTypes — MixedOfficial Mongoose documentation for Schema.Types.Mixed, including the markModified() requirement and shorthand definitions.
  • Mongoose QueriesOfficial Mongoose query API reference covering dot notation filters, projection, sort, limit, and the lean() modifier.
  • MongoDB Query on Embedded DocumentsMongoDB official tutorial for querying nested document fields using dot notation, $elemMatch, and field existence operators.
  • Mongoose TypeScript SupportOfficial Mongoose guide for TypeScript generics, HydratedDocument, schema inference, and model typing in Mongoose 6+.
  • MongoDB Indexes on Embedded FieldsMongoDB documentation for multikey indexes on array fields, compound index restrictions, and sparse index configuration.