What fields exist in a MongoDB collection? Schema inspection how-to
Posted by Kyle Hankinson August 9, 2026
Point a SQL developer at an unfamiliar database and the first keystrokes are automatic: DESCRIBE, \d, or a query against information_schema.columns. Point the same developer at an unfamiliar MongoDB collection and there is nothing to type, because there is no catalog to ask. Each document carries its own structure, so "the schema" is not a definition stored anywhere; it is an emergent property of whatever documents happen to be in the collection right now. That is not a gap you work around once, it is a question you will answer weekly: what fields exist in here, and can I trust them?
The answers below run from a five-second eyeball to a full census with type checking, all executed on MongoDB 8.2.11 and re-checked on 7.0.37. The guinea pig is a contacts collection of six documents seeded with realistic inconsistencies: one missing email, one missing plan, two with createdAt stored as a string instead of a date, two with a nested address object (whose shapes also differ), and stray one-off fields like legacyId and referredBy.
The quick look is findOne(), or a few random documents via $sample (available since 3.2):
db.contacts.aggregate([ { $sample: { size: 2 } } ])
// { name: 'Donald K.', email: 'don@example.com',
// createdAt: ISODate('2025-07-04T12:00:00.000Z'), plan: 'free', referredBy: 'ada@example.com' }
// { name: 'Alan Turing', email: 'alan@example.com',
// createdAt: '2023-11-02', plan: 'pro', phone: '+44 20 946 0018' }
Useful, and already suspicious: one createdAt is an ISODate, the other a bare string. But sampling can only show you the fields it happens to draw. To actually enumerate the keys, you turn each document into data about itself. The $objectToArray operator (3.4.4+) converts a document into an array of {k, v} pairs; $unwind gives each pair its own document; $group counts them:
db.contacts.aggregate([
{ $project: { fields: { $objectToArray: "$$ROOT" } } },
{ $unwind: "$fields" },
{ $group: { _id: "$fields.k", count: { $sum: 1 } } },
{ $sort: { count: -1, _id: 1 } }
])
// { _id: '_id', count: 6 } { _id: 'plan', count: 5 }
// { _id: 'createdAt', count: 6 } { _id: 'address', count: 2 }
// { _id: 'name', count: 6 } { _id: 'legacyId', count: 1 }
// { _id: 'email', count: 5 } { _id: 'phone', count: 1 }
// { _id: 'referredBy', count: 1 }, { _id: 'tags', count: 1 }
This is the closest MongoDB gets to information_schema.columns, and the counts are the payload: email exists on only 5 of 6 documents, and four fields exist exactly once. Every count below the collection total is a null check your application code needs. (The pipeline mechanics here, $group and friends, are covered in the aggregation pipeline primer.) If a 2012-era Stack Overflow answer steers you toward mapReduce for this job instead, skip it; mapReduce has been deprecated since MongoDB 5.0.
Same field, different types
Presence is only half the schema question. The other half is whether a field holds the same BSON type everywhere, and this is where document stores quietly hurt you. Extending the census with the $type operator groups by field and type together; collapsing that with a second $group reports only the drifters:
db.contacts.aggregate([
{ $project: { fields: { $objectToArray: "$$ROOT" } } },
{ $unwind: "$fields" },
{ $group: { _id: { field: "$fields.k", type: { $type: "$fields.v" } },
count: { $sum: 1 } } },
{ $group: { _id: "$_id.field",
types: { $push: { type: "$_id.type", count: "$count" } },
typeCount: { $sum: 1 } } },
{ $match: { typeCount: { $gt: 1 } } }
])
// { _id: 'createdAt',
// types: [ { type: 'date', count: 4 }, { type: 'string', count: 2 } ],
// typeCount: 2 }
One field, two types, and the damage is concrete: a date-range filter like { createdAt: { $gte: ISODate("2023-01-01") } } matched 4 of the 6 documents on this collection. The two string-dated contacts are invisible to it, with no error, because BSON compares dates and strings as different types. If your "recent signups" numbers look low, run this detector before doubting the query.
Nested objects need one more hop. Pointing $objectToArray at the subdocument instead of $$ROOT enumerates its keys; on this data it showed address.state exists on only one of the two addresses:
db.contacts.aggregate([
{ $match: { address: { $type: "object" } } },
{ $project: { fields: { $objectToArray: "$address" } } },
{ $unwind: "$fields" },
{ $group: { _id: "$fields.k", count: { $sum: 1 } } }
])
// { _id: 'city', count: 2 }, { _id: 'country', count: 2 }, { _id: 'state', count: 1 }
Two footnotes on the census. It reads every document, so on a big collection either put a $match in front or run it on a $sample first and accept approximate counts. And indexes are free schema documentation: db.contacts.getIndexes() on this collection revealed a unique sparse index on email and a compound { plan: 1, createdAt: -1 }, which tells you which fields the application actually queries and which it insists are unique, before you read a line of its code.
For day-to-day browsing you rarely want to type the census by hand. The collection inspector in SQLPro for MongoDB shows sampled field names with their BSON types alongside the index list and collection stats, which makes it the natural first pass on an unfamiliar collection; because it samples rather than scans, a rare field can escape it, and that is exactly when the census pipeline above earns its keep.
Opting back into a schema
Once inspection shows drift, MongoDB can be told to stop accepting it. A $jsonSchema validator (3.6+) attaches rules to a collection, per the schema validation documentation:
db.createCollection("contacts_v", {
validator: { $jsonSchema: {
bsonType: "object",
required: ["name", "email", "createdAt"],
properties: {
name: { bsonType: "string" },
email: { bsonType: "string", pattern: "^.+@.+$" },
createdAt: { bsonType: "date" },
plan: { enum: ["free", "pro", "team"] }
}
} }
})
Inserting a document with a string createdAt into that collection now fails with error code 121, Document failed validation, and the error detail names the offender precisely: propertyName: 'createdAt', specifiedAs: { bsonType: 'date' }, consideredValue: '2024-01-20', consideredType: 'string'. The same insert with a real date succeeds. For existing collections, collMod attaches a validator after the fact, with validationLevel: "moderate" available to grandfather old documents while policing new writes.
That is the honest shape of "schemaless": the schema exists whether or not the database enforces it, and the census pipeline, the type-drift detector, and a validator are how you find it, trust it, and keep it. The same discipline applies when translating SQL habits to MongoDB generally: the flexibility is real, but so is the bookkeeping it hands back to you.
About the author — Kyle Hankinson is the founder and sole developer of SQLPro Studio and the Hankinsoft Development suite of database tools. He has been building native macOS and iOS applications since 2010.
Try SQLPro Studio — A powerful database manager for MySQL, PostgreSQL, Microsoft SQL Server, SQLite, Oracle, and Snowflake. Available on macOS, iOS, and Windows.
Download Free Trial View Pricing Compare