SQLPro for MSSQL Blog

Tips, tutorials, and updates for SQL Server management.


SQLPro Studio is now available on Android

Posted by Kyle Hankinson


SQLPro Studio has been a native database client on the Mac since 2015, and on iPhone, iPad and Windows for years now. Today it comes to the platform people have asked about most: SQLPro Studio is available on Google Play for Android phones and tablets.

Get SQLPro Studio on Google Play

It is a native Android app, written in Kotlin, with the same database drivers that power the desktop and iOS versions. There is no web view, no Java desktop runtime and no remote service in the middle. The app talks to your database directly, the same way it does on your Mac.

SQLPro Studio for Android running a query with the results grid below the editor The database tree in SQLPro Studio for Android showing tables, views and columns

What is in the first release

The databases you already use. MySQL and MariaDB, PostgreSQL (including hosted services such as Amazon RDS, Azure and Heroku), Microsoft SQL Server 2005 and later, Oracle, MongoDB, SQLite and more. Connections are per engine, so a MySQL connection gets the MySQL editor and the MySQL tree, exactly as on the desktop.

A real query editor. Syntax highlighting with light and dark themes, autocompletion for tables and columns, several query tabs open at once, find and replace, automatic formatting, and the choice of running one statement, the current selection or the whole script.

Browse and edit, not just read. A context aware database tree gives quick access to tables, views, columns and indexes. Tap a table to see its rows, then edit, insert or delete rows directly in the grid. A table designer creates and alters tables, and results export to CSV, JSON or XML. Query history and saved favourites are there too.

Editing a row in SQLPro Studio for Android SQLPro Studio for Android in the dark theme

Secure connections. SSH tunnelling with password or private key authentication, SSL and TLS options per engine, and a scan of the local network to find database servers. Connection details are stored in the Android keystore on the device. A biometric app lock (fingerprint or face) keeps the app closed to anyone else who picks up your phone.

Sample databases built in. Not near your own server? The app ships with sample connections so you can explore the editor, the tree and the grid without setting anything up.

Licensing

SQLPro Studio for Android is free to download. A Premium unlock is needed to interact with result sets, and there are two ways to get it:

  • Use the key you already have. If you subscribe to SQLPro Studio (monthly or yearly), enter your existing license key in the Android app and it unlocks. Student license keys work the same way. Subscriptions now cover macOS, iOS, Android and Windows.
  • Buy inside the app. Google Play offers a monthly or yearly subscription and a one time Lifetime purchase, managed through your Play account.

One note for Lifetime customers: lifetime licenses have always been per platform (a macOS lifetime key does not unlock Windows, and the same applies to Android). An Android lifetime license is bought through Google Play inside the app.

SQLPro for MySQL and SQLPro for MSSQL too

If you only work with one database engine, the single-database apps are also on Google Play:

They share the same code as the Studio app, and a SQLPro Studio subscription key unlocks them as well.

Requirements

Android 10 or later, on phones and tablets, in portrait or landscape. The app follows your system light or dark setting.

Feedback

This is the first Android release, and the roadmap is driven by what people ask for. If something looks wrong, or a feature you rely on from the desktop app is missing, please open an issue on GitHub or reach out through the support page.


Tags: Feature

SQLPro Keyboard Shortcuts

Posted by Kyle Hankinson


Most of the time you spend in a database client is spent in three places: the query editor, the results grid, and the connection sidebar. Moving between them with the mouse works, but it is the slowest way to do it. This article lists every keyboard shortcut SQLPro for MSSQL ships with, notes which actions deliberately arrive unassigned, and shows how to rebind any of them.

Where the shortcuts live

On macOS, open Preferences (Cmd + ,) and choose Keyboard in the sidebar. Every shortcut listed below has its own row. Click the field beside an action, press the combination you want, and it is saved immediately. There is no Apply button.

To remove a binding, click the field and press the delete button that appears at its right edge. To restore an individual shortcut, clear it and reopen the window.

On Windows, open Preferences and choose Keyboard. The Windows build currently exposes a smaller set of rebindable actions than the macOS build; the table further down lists what is configurable today.

macOS defaults

Running queries

Action Default
Query execution Shift + Cmd + E
Query execution (secondary) not assigned
Format query Shift + Cmd + K
Comment/uncomment Cmd + -

Query execution (secondary) exists so you can bind a second key to the same action. A common setup is to leave the primary at Shift + Cmd + E and add Cmd + Return as the secondary, which matches the muscle memory from a lot of other tools.

Comment/uncomment works on the current line, or on every line touched by the selection. It uses -- followed by a space, which matters for MySQL, where a comment marker without trailing whitespace is not treated as a comment.

Connections and navigation

Action Default
Connect Shift + Cmd + C
Focus database tree Ctrl + D
Focus database tree search Ctrl + F
Previous tab Shift + Cmd + [
Next tab Shift + Cmd + ]

Ctrl + F jumps straight to the sidebar filter, which is usually faster than scrolling a large schema. Note that these two use Ctrl rather than Cmd, so they do not collide with the standard macOS find and duplicate shortcuts in the editor.

Alongside the tab shortcuts above, Cmd + 1 through Cmd + 9 switch directly to a tab by position. That behaviour is on by default and can be turned off with the checkbox in the same Keyboard preferences pane, which is worth doing if you would rather those keys stay free.

Results

Action Default
Hide/show query results Cmd + K
Find column Cmd + B
Toggle results/messages not assigned
Copy selected with headers as CSV not assigned
Export full result set as CSV not assigned
Export full result set as JSON not assigned
Export full result set as XML not assigned

Find column is the one people tend to miss. On a result set that is a hundred columns wide, Cmd + B beats horizontal scrolling.

The five unassigned actions here are the ones worth setting up first, because they have no default and are easy to reach for repeatedly. If you export the same result set shape every day, giving CSV export a binding removes several clicks each time.

Text editing

Action Default
Make uppercase not assigned
Make lowercase not assigned

Both are unassigned by default. They apply to the current selection in the editor.

Multiple cursors

Multi-cursor editing has its own set of keys, and they are not configurable:

  • Hold Option and drag the mouse up or down between lines
  • Cmd + Option + ↑/↓ to add cursors above or below
  • Shift + Option + I turns a multi-line selection into one cursor per line

Once several cursors are active, the usual text-movement keys apply to all of them at once: ←/→ by character, Option + ←/→ by word, adding Shift to any of those to select rather than move. Cmd + Delete deletes to the end of the next word from every cursor, and Option + Delete deletes to the end of the line.

Windows defaults

Action Default
Query execution Ctrl + Shift + E
Format query Ctrl + K
Hide/show query results Ctrl + R
Query history Ctrl + Shift + H

The first three are rebindable from Preferences > Keyboard. Query history has a working default but no row in the preferences pane yet, so it cannot currently be changed.

If you are coming from SQL Server Management Studio, the layout will feel close but not identical. Ctrl + K is format-query here rather than the first half of a chord, so the SSMS comment pair Ctrl + K, C and Ctrl + K, U do not carry over.

Picking bindings that stick

Two things are worth knowing before you rebind much.

Shortcuts are validated as you set them. If you choose a combination the system has already claimed, or one already used by another action in SQLPro for MSSQL, the field will reject it rather than silently create a conflict. Reassigning an action to the combination it already has is allowed, so you can safely re-enter the same keys.

Your choices are stored per application rather than per connection, so a binding you set while working against Postgres applies just as well when you open a SQL Server connection later.

If a shortcut you want is missing

The list above is what exists today. If there is an action you reach for constantly and it has no row in the Keyboard pane, that is worth telling us about, because the pane is straightforward to extend and requests genuinely drive what gets added. The same goes for defaults that feel wrong for your platform.


What fields exist in a MongoDB collection? Schema inspection how-to

Posted by Kyle Hankinson


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.


Tags: MongoDB

E11000 Duplicate Key Error in MongoDB (and dup key: null)

Posted by Kyle Hankinson


Somewhere in your logs is a write failure that makes no sense:

E11000 duplicate key error collection: shop.users index: email_1 dup key: { email: null }

You never inserted null into email. You may never have inserted email at all. That is precisely the problem, and it is the most-viewed MongoDB error on Stack Overflow for a reason: the root cause lives in an index you may not remember creating, and the intuitive fixes make it worse. This article reproduces the error on MongoDB 8.2, explains the null trap, and works through the fixes in the order you should try them.

Reading the message

E11000 is MongoDB's unique constraint violation, and everything you need is packed into the text:

  • collection: shop.users is the namespace (database shop, collection users).
  • index: email_1 is the index that rejected the write. The name encodes the key pattern: field email, ascending. Compound indexes chain them, like email_1_tenant_1.
  • dup key: { email: null } is the value that already exists in the index.

The error code is 11000, which matters when you handle it in application code. When the duplicate is a real value, the message is doing its job honestly:

E11000 duplicate key error collection: shop.users index: email_1 dup key: { email: "a@b.com" }

The confusion starts when that value is null and your code never wrote one.

The null trap, in four lines

Unique indexes in MongoDB index every document in the collection, whether or not the document contains the indexed field. A document with no email field is stored in the index under the key null. Unique means at most one document per key, so at most one document in the whole collection may omit the field. The second one collides:

db.users.createIndex({ email: 1 }, { unique: true })

db.users.insertOne({ name: "Ana" })   // fine: occupies the null slot
db.users.insertOne({ name: "Ben" })   // E11000 ... dup key: { email: null }

Verified on MongoDB 8.2.11 and 7.0.37; the error text is identical on both. Very old servers (the 2.x and 3.x era) printed dup key: { : null } without the field name, which is the variant fossilized in the top Stack Overflow answers.

This bites hardest in two situations. First, adding a unique field to an existing collection: every pre-existing document lacks the field, so the second write after the index appears fails. Second, stale indexes: you had unique: true on a field in a Mongoose schema, renamed or removed the field, and the index quietly stayed behind. Mongoose's unique option is not a validator; it is an instruction to create an index once, and deleting it from the schema drops nothing on the server.

Find the index you forgot about

The index named in the error is the whole diagnosis. List them:

db.users.getIndexes()
[
  { v: 2, key: { _id: 1 }, name: '_id_' },
  { v: 2, key: { email: 1 }, name: 'email_1', unique: true }
]

Anything with unique: true on a field your current code no longer populates is your suspect. If you browse your data in a GUI, this check is a glance rather than a shell command; SQLPro for MongoDB shows every collection's index list with unique badges in its collection inspector, right next to the sampled fields and their BSON types, which makes a stale email_1 stand out immediately.

The fixes, ranked

1. If the index is obsolete, drop it. The rename-and-leftover-index case needs nothing cleverer:

db.users.dropIndex("email_1")

2. If you want uniqueness only when the field exists, use a partial index. This is the modern fix (available since MongoDB 3.2). The index only includes documents matching a filter, so documents without the field are simply not indexed and never collide:

db.users.dropIndex("email_1")
db.users.createIndex(
  { email: 1 },
  { unique: true, partialFilterExpression: { email: { $exists: true } } }
)

db.users.insertOne({ name: "Ana" })                      // ok
db.users.insertOne({ name: "Ben" })                      // ok now
db.users.insertOne({ name: "Cy", email: "a@b.com" })     // ok
db.users.insertOne({ name: "Di", email: "a@b.com" })     // still E11000

All four results verified on 8.2. Real duplicates still fail, missing fields sail through. One caveat: an explicit email: null value satisfies $exists: true, so if your application writes literal nulls, filter on type instead: partialFilterExpression: { email: { $type: "string" } }.

3. The sparse index is the legacy version of the same idea. { unique: true, sparse: true } also skips documents missing the field (verified: both no-email inserts succeed). It predates partial indexes, only supports "field is absent" as its condition, and the manual recommends partial indexes instead because sparse indexes can be silently ignored for sorts and return incomplete results when used to cover queries. Reach for it only on servers older than 3.2, which in 2026 should be nobody.

4. Or actually populate the field. If every document should have an email, the fix is a backfill, not an index change. The error is doing you a favor.

Before you create a unique index: find existing duplicates

Building a unique index on a collection that already contains duplicates fails mid-build with the same E11000, naming whichever duplicate it hit first. Check first with a $group:

db.contacts.aggregate([
  { $group: { _id: "$email", count: { $sum: 1 }, ids: { $push: "$_id" } } },
  { $match: { count: { $gt: 1 } } }
])
{ _id: null, count: 2, ids: [ ObjectId('...776'), ObjectId('...777') ] }
{ _id: 'a@b.com', count: 2, ids: [ ObjectId('...773'), ObjectId('...774') ] }

Note the first row: documents missing the field group under _id: null, exactly mirroring how the index will treat them. That row predicts a dup key: { email: null } failure before you ever build the index. Decide per group which _ids to keep, delete the rest, then create the index. The same keep-one-per-group reasoning applies in relational databases, where we covered it in how to find and delete duplicate rows in SQL.

The upsert race, briefly

There is one variant where E11000 appears without any modeling mistake. Two concurrent upserts filter on the same key, both find nothing, and both proceed to insert; one wins, the other gets E11000. Since MongoDB 4.2 the server retries most of these internally, but the race can still surface under load or when the filter does not exactly match the unique key. The fix is in application code: treat error code 11000 on an upsert as "someone else inserted first" and retry the operation once. On the second pass the document exists and the upsert takes its update path.

E11000 always tells the truth about one thing: some unique index rejected a key. When the key is a value, find the other document holding it. When the key is null, stop looking at your data and start looking at getIndexes(), because the collision is between two documents that never mention the field at all.


Tags: MongoDB

MongoDB aggregation pipeline basics: $match, $group, $sort, $project

Posted by Kyle Hankinson


Sooner or later every MongoDB query outgrows find(). The moment you need a GROUP BY, a computed column, or a join, you are writing an aggregation pipeline, and the pipeline rewards one mental model above all others: it is a conveyor belt. Documents enter at the left, each stage transforms or filters what it receives and passes the result to the next stage, and order matters exactly as it does with Unix pipes. That is the whole trick. Most beginner pain comes not from the model but from two syntax rules of $group, and this article reproduces both mistakes on purpose so you recognize the symptoms.

Everything below ran on MongoDB 8.2.11 (official mongo:8 Docker image), with the failure cases re-confirmed on 7.0.37, against 1,000 generated order documents shaped like this:

db.orders.findOne()
// {
//   orderId: 1,
//   category: 'toys',
//   region: 'east',
//   quantity: 5,
//   price: 141.25,
//   placedAt: ISODate('2026-05-16T00:00:00.000Z')
// }

Suppose the question is: for electronics, what is the revenue per region, highest first? In SQL that is one statement; in MongoDB it is four stages, and the productive way to write it is one stage at a time, inspecting output after each addition.

Stage one, $match, is the WHERE. Run it alone first (a trailing $count is a cheap sanity check):

db.orders.aggregate([
  { $match: { category: "electronics" } },
  { $count: "n" }
])
// [ { n: 205 } ]

205 of 1,000 documents survive. Put $match as early as possible: at the beginning of a pipeline it can use an index, per the aggregation pipeline documentation, and every document it discards is work no later stage has to do.

Stage two, $group, is the GROUP BY. The grouping key goes in a field that must be called _id, and accumulators like $sum and $avg compute the aggregates:

db.orders.aggregate([
  { $match: { category: "electronics" } },
  { $group: { _id: "$region",
              revenue: { $sum: { $multiply: ["$price", "$quantity"] } },
              orders:  { $sum: 1 } } }
])
// { _id: 'north', revenue: 15388.880000000001, orders: 61 }
// { _id: 'south', revenue: 13978.19, orders: 48 }
// { _id: 'west',  revenue: 10811.94, orders: 48 }
// { _id: 'east',  revenue: 14458.869999999999, orders: 48 }

Note the $ prefixes: "$region" and "$price" mean "the value of that field in the incoming document." (The floating-point dust on north is ordinary double arithmetic, cleaned up below.) $sum: 1 adds one per document, which is how you spell COUNT(*). Grouping with _id: null collapses everything into one row; that variant returned the grand total, 1,000 orders and 295,749.87 in revenue.

Stage three, $sort, orders what $group emitted (-1 descending), and stage four, $project, shapes the final output: here it renames _id back to something readable and rounds the noise away.

db.orders.aggregate([
  { $match: { category: "electronics" } },
  { $group: { _id: "$region",
              revenue: { $sum: { $multiply: ["$price", "$quantity"] } },
              orders:  { $sum: 1 } } },
  { $sort: { revenue: -1 } },
  { $project: { _id: 0, region: "$_id", orders: 1, revenue: { $round: ["$revenue", 2] } } }
])
// { orders: 61, region: 'north', revenue: 15388.88 }
// { orders: 48, region: 'east',  revenue: 14458.87 }
// { orders: 48, region: 'south', revenue: 13978.19 }
// { orders: 48, region: 'west',  revenue: 10811.94 }

That is the whole pattern: filter, group, sort, shape. Since MongoDB 4.2, $set and $unset exist as friendlier aliases for the add-a-field and drop-a-field uses of $project.

The two ways your $group goes wrong

Both classic $group bugs share a symptom profile: no error, plausible-looking output, wrong numbers. Here is each one, run for real.

Mistake one: dropping the $ prefix on the grouping key. _id: "category" does not mean "group by category"; it means "group by the constant string category", so every document lands in a single bucket:

db.orders.aggregate([ { $group: { _id: "category", n: { $sum: 1 } } } ])
// { _id: 'category', n: 1000 }   <- one bucket, not five

Mistake two: dropping the $ inside an accumulator. $avg: "price" averages the constant string "price", which is not a number, so you get null; the same slip inside $sum quietly produces 0 because $sum ignores non-numeric values:

db.orders.aggregate([ { $group: { _id: "$category", avgPrice: { $avg: "price" } } } ])
// { _id: 'books', avgPrice: null } ...
db.orders.aggregate([ { $group: { _id: "$category", total: { $sum: "price" } } } ])
// { _id: 'books', total: 0 } ...

With the $ restored, avgPrice came back as 92.65 for books and 97.08 for electronics. A $sum column of zeros or an $avg of nulls is almost always this bug.

There is a third stumble worth naming: forgetting that the grouping key now lives in _id. Filtering on the original field name after a $group matches nothing at all:

db.orders.aggregate([
  { $group: { _id: "$category", n: { $sum: 1 } } },
  { $match: { category: "books" } }    // wrong: that field no longer exists
])
// []
db.orders.aggregate([
  { $group: { _id: "$category", n: { $sum: 1 } } },
  { $match: { _id: "books" } }         // right
])
// [ { _id: 'books', n: 208 } ]

That post-$group $match is also precisely how HAVING translates. Categories with more than 200 orders:

db.orders.aggregate([
  { $group: { _id: "$category", n: { $sum: 1 } } },
  { $match: { n: { $gt: 200 } } },
  { $sort: { n: -1 } }
])
// { _id: 'books', n: 208 }, { _id: 'electronics', n: 205 }, { _id: 'kitchen', n: 201 }

And COUNT DISTINCT is a two-stage idiom, group on the field then count the groups:

db.orders.aggregate([ { $group: { _id: "$region" } }, { $count: "distinctRegions" } ])
// [ { distinctRegions: 4 } ]

Memory limits, briefly

Each pipeline stage is capped at 100 MB of RAM. On MongoDB 6.0 and later the allowDiskUseByDefault parameter lets stages that exceed it spill to temporary disk files automatically, per the aggregation pipeline limits documentation, so the once-notorious "Exceeded memory limit for $group" error is mostly a pre-6.0 experience. On 4.x and 5.x you opt in per query with allowDiskUse: true. Either way, an early $match that shrinks the stream is the better fix than a bigger spill file. And if an older Stack Overflow answer suggests mapReduce for any of this, note it has been deprecated since MongoDB 5.0; the pipeline is the replacement.

The stage-at-a-time habit is also the debugging technique: when a pipeline misbehaves, delete stages from the end until the output looks right again, and the last stage you removed is your suspect. A client can shorten that loop considerably. The aggregation builder in SQLPro for MongoDB edits the pipeline as a stage list with insertable $match, $group, $sort, $project, and $lookup (and more) templates, and re-running after each edit puts the intermediate documents in a results grid.

From here, the natural next steps are joins with $lookup, covered in the SQL to MongoDB translation guide, and turning the pipeline loose on schema exploration, which is how you find out what fields a collection actually contains.


Tags: MongoDB

More articles: