Microsoft Entra ID (MFA) sign-in for Azure SQL on Mac, iPhone and iPad

Posted by Kyle September 28, 2026


A lot of Azure SQL databases no longer have a SQL login you can use. Many organizations turn SQL authentication off entirely and require everyone to sign in with their work account, usually with multi-factor authentication on top. Until now, that left Mac, iPhone and iPad users without a native way in.

That changes with the latest update. SQLPro Studio and SQLPro for MSSQL now sign in to SQL Server with Microsoft Entra ID (the service formerly called Azure Active Directory) on macOS, iPhone and iPad. You sign in with Microsoft the same way you do everywhere else at work, MFA prompt included, and the app connects with the token Microsoft issues. The Windows apps already supported Entra ID sign-in; this brings the Apple platforms up to the same level.

What you can connect to

Entra ID sign-in works with the SQL Server services that accept it:

  • Azure SQL Database
  • Azure SQL Managed Instance

Nothing changes for SQL Server authentication or Windows authentication. Existing connections keep working exactly as before.

Two ways to sign in

Open a SQL Server connection and pick one of the two new authentication types.

Microsoft Entra - Interactive (MFA). This is the one most people want. When you connect, a Microsoft sign-in window opens. Choose your account, complete whatever your organization asks for (password, Authenticator push, security key, passkey) and the connection opens. The window always shows the account picker, so if you are signed in to more than one Microsoft account you pick the right one instead of the browser quietly reusing the last one.

Microsoft Entra - Device Code. For when a browser window is not practical, or you would rather finish the sign-in on another device. The app shows a short code; go to microsoft.com/devicelogin on your phone or any computer, enter the code, and sign in there. The app waits and connects as soon as you are done.

In both cases the login field is your work address, for example jane@contoso.com. There is no password to enter in the app.

You will not be asked every time

After the first sign-in, the app keeps a refresh token in the system keychain and gets new access tokens quietly in the background. Reconnecting, opening a second window on the same server, or relaunching the app does not bring the sign-in window back. You only see Microsoft again when your organization's policy says so (a password change, a revoked session, or a sign-in frequency rule).

A few details worth knowing:

  • Your password never passes through the app. You type it into Microsoft's own sign-in page. The app only receives the token Microsoft issues.
  • The account is checked. If you sign in as a different account from the one in the connection's login field, the app refuses the token and tells you which account Microsoft signed in, rather than connecting you as the wrong person.
  • Several connections, one prompt. Restore a workspace with five tabs on the same server and you get one sign-in, not five.

The tenant setting

Most people can ignore this. By default the sign-in uses the directory that belongs to the domain of your login, so jane@contoso.com signs in to the Contoso tenant.

Set the tenant yourself if you are a guest in another organization's directory, or if your organization's sign-in domain differs from the address in your login. On the Mac it is the Entra ID tenant field in the connection's advanced settings; on iPhone and iPad it is the Tenant field on the connection. Either the tenant's domain (company.onmicrosoft.com) or its tenant ID works.

First sign-in: consent

The first time anyone in an organization signs in, Microsoft asks whether the app may access Azure SQL Database on their behalf. Accept it once and it is not asked again.

Some organizations do not let users approve apps themselves. In that case Microsoft shows a message saying administrator approval is needed, or the sign-in ends with an error such as AADSTS65004 or access_denied. An administrator can approve the app for everyone from the Entra admin center; Microsoft describes the steps in grant tenant-wide admin consent to an application. The application ID to look for is a61530ad-aa3d-43bc-8d9d-e4e264e28263.

On the database side

Signing in with Microsoft only proves who you are. The server still has to know you. If you are setting up Entra ID access on Azure SQL for the first time:

  1. The logical server (or managed instance) needs a Microsoft Entra admin. You set it in the Azure portal under the server's Microsoft Entra ID settings.
  2. Each person or group needs a user in the database, created by that admin:
CREATE USER [jane@contoso.com] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [jane@contoso.com];

Groups work the same way, using the group's display name, which saves adding people one at a time. Microsoft's overview of Entra authentication for Azure SQL and the configuration guide cover the rest.

If it does not connect

  • "Client with IP address ... is not allowed to access the server" (Msg 40615). The sign-in worked, but the Azure SQL firewall is blocking your network. Add your address in the Azure portal under the server's networking settings, or ask whoever manages it. See Azure SQL firewall rules.
  • Login failed for a user that should exist. Check the CREATE USER ... FROM EXTERNAL PROVIDER step above, and that you are connecting to the database the user was created in.
  • "Tenant not found" (AADSTS90002). Your login's domain is not the domain your directory uses for sign-in. Enter the tenant explicitly, as described above.
  • The sign-in window was closed or timed out. Just connect again. Device codes expire after about 15 minutes, so a new one is issued each time.
  • Signed in as the wrong account. The error names the account Microsoft used. Connect again and choose the right one in the account picker, or correct the login.

Where to get it

Entra ID sign-in is in the current releases for macOS, iPhone and iPad, in both apps. It is not in the Android apps yet. If you are new to SQL Server on the Mac, our guide to connecting to SQL Server from a Mac walks through the rest of the connection settings.

If your organization's sign-in setup does something we have not covered here, please open an issue on GitHub with the error you see. We would like to hear about it.


SQLPro Studio is now available on Android

Posted by Kyle September 11, 2026


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 July 27, 2026


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 Studio 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 Studio, 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 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.


Tags: MongoDB

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

Posted by Kyle August 2, 2026


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

More articles: