- name
- design-azure-sql-schema
- description
- Designs tables for Azure SQL Database so the first index or long value does not force a rebuild. Covers index key limits in bytes, why Unicode sizing makes NVARCHAR(850) and NVARCHAR(450) the real ceilings, collation decided once at CREATE DATABASE, the implicit conversion that turns a lookup into a scan, identity gaps of a thousand after a restart, unique columns that accept exactly one NULL. Use when creating or reviewing tables, porting a PostgreSQL or MySQL schema to Azure SQL Database, choosing a key, a string length or a collation, or reports a warning about maximum key length, an insert failing long after its migration, a lookup that suddenly scans, identity values that jumped, or a duplicate key on NULL. The engine rule under the mappers: ef-core-azure-sql and sqlalchemy-azure-sql own how each expresses it, t-sql-correctness syntax, vector-search-azure-sql vector columns.
# Design a schema Azure SQL Database will not make you rebuild
These are the schema decisions Azure SQL Database punishes **later**, not at `CREATE TABLE`.
Normalisation and naming are not here: they are the same everywhere.
Everything below was measured on 2026-08-28 against a live engine where
`SERVERPROPERTY('EngineEdition')` returns `5` and `Edition` is `SQL Azure`.
Open [the measured statements](references/key-bytes-collation-and-identity-gaps.md) when a number
below disagrees with your engine, and before changing one.
| | |
|---|---|
| The DDL | succeeds, sometimes with a warning nobody reads |
| The migration | succeeds |
| The failure | arrives on a row, a plan or a failover, weeks later, in production |
**A syntax error costs minutes. Each item here costs a table rebuild, a collation rebuild or a
duplicated key.**
## 1. The index key budget is in bytes, and Unicode spends two per character
**1700 bytes for a nonclustered index key, 900 for a clustered one**, and a composite key spends
the **sum**. `NVARCHAR(n)` costs `2n` bytes, so:
| Position | Widest safe Unicode column | Widest safe UTF-8 column |
|---|---|---|
| Nonclustered index or unique constraint | `NVARCHAR(850)` | `VARCHAR(1700)` |
| Clustered index or clustered primary key | `NVARCHAR(450)` | `VARCHAR(900)` |
| Anything not in a key | unbounded | unbounded |
`NVARCHAR(450)` is the only length safe in **every** index position, which is why EF Core narrows
an indexed string to it.
Over-shooting is a **warning**, message 1945 at severity 10. The index exists, the migration
reports success, short values insert fine:
```text
Warning! The maximum key length for a nonclustered index is 1700 bytes.
The index 'ux_acct_email' has maximum length of 2000 bytes.
For some combination of large values, the insert/update operation will fail.
```
The failure waits for the first long row, and this one is an error:
```text
Msg 1946, Level 16, State 3
Operation failed. The index entry of length 1800 bytes for the index 'ux_acct_email'
exceeds the maximum length of 1700 bytes for nonclustered indexes.
```
**Consequence of being wrong: a write path that has worked for months starts rejecting exactly the
rows that matter,** and the fix is an `ALTER COLUMN` plus an index rebuild on a live table.
- **`INCLUDE` columns do not count against the budget.** A covering index can include
`NVARCHAR(MAX)`.
- **A foreign key column must match the parent's length and collation exactly**, `Msg 1753` and
`Msg 1757`, so an oversized parent key propagates to every child.
- `NVARCHAR(MAX)`, `json` and `vector` cannot be key columns at all: `Msg 1919`, `Msg 1978`.
## 2. Collation is chosen at CREATE DATABASE, and it decides two things
A new database with no collation stated gets `SQL_Latin1_General_CP1_CI_AS`.
`CI` means identifiers are not unique the way the design assumes:
```sql
CREATE TABLE dbo.tokens (t NVARCHAR(64) NOT NULL PRIMARY KEY);
INSERT INTO dbo.tokens (t) VALUES (N'aB1x');
INSERT INTO dbo.tokens (t) VALUES (N'Ab1X');
-- Msg 2627, Violation of PRIMARY KEY constraint. The duplicate key value is (Ab1X).
```
**Consequence of being wrong: any column holding a case-significant token, API key, slug or short
link code collides at roughly the rate its alphabet implies**, as a user-visible insert failure.
The fix is per column:
```sql
ALTER TABLE dbo.tokens ALTER COLUMN t NVARCHAR(64) COLLATE Latin1_General_CS_AS NOT NULL;
```
**State the collation in `CREATE DATABASE`, or accept the default deliberately.** There is no
server collation to inherit, and `ALTER DATABASE ... COLLATE` afterwards re-collates nothing that
already exists: it succeeds, changes only what is created next, and leaves you joining across a
collation conflict, `Msg 468`. The second half of the default's cost is section 3.
## 3. The implicit conversion that costs the seek
The common drivers bind a string parameter as Unicode by default. Under the **default** collation,
comparing that parameter to a `VARCHAR` column converts the **column**, and a converted column
cannot be sought. Measured over 20,000 rows with a unique index on `email`:
| Column | Collation | Parameter | Plan | Logical reads |
|---|---|---|---|---|
| `VARCHAR(320)` | `SQL_Latin1_General_CP1_CI_AS` (the default) | `NVARCHAR` | **Index Scan** | **80** |
| `VARCHAR(320)` | `Latin1_General_CI_AS` | `NVARCHAR` | Index Seek | 2 |
| `VARCHAR(850)` | `Latin1_General_100_CI_AS_SC_UTF8` | `NVARCHAR` | Index Seek | 2 |
| `NVARCHAR(320)` | any | `NVARCHAR` | Index Seek | 2 |
| `VARCHAR(320)` | the default | `VARCHAR` | Index Seek | 2 |
```text
|--Index Scan(OBJECT:([dbo].[users_v].[ux_v]),
WHERE:(CONVERT_IMPLICIT(nvarchar(320),[dbo].[users_v].[email],0)=[@p]))
```
Row two seeks because a Windows collation lets the optimizer build a range with
`GetRangeThroughConvert`. **A SQL collation cannot, and the default is a SQL collation.**
**Consequence of being wrong: every lookup on that column reads the whole index instead of three
pages, forever, with no error and no warning.** Two designs avoid it, and one has to be chosen up
front:
1. **`NVARCHAR` for anything a driver will bind a string to.** Correct, and it costs key bytes.
2. **A UTF-8 collation on `VARCHAR`** when the data is ASCII-dominant and the key is tight.
`VARCHAR(1700)` then fits a nonclustered key, and the seek survives because UTF-8 collations are
Windows collations. The cost is that `VARCHAR(n)` counts **bytes**.
Do not mix. `VARCHAR` under the default collation plus an application binding Unicode is row one.
## 4. Identity gaps are routine here, and the remembered fix does not exist
Measured across one engine restart, which is what a planned failover looks like from inside:
| Column | Last value before | First value after | Gap |
|---|---|---|---|
| `INT IDENTITY` | 3 | **1002** | 999 |
| `BIGINT IDENTITY` | 3 | **10002** | 9999 |
| `SEQUENCE`, default cache | 2 | **51** | 49 |
| `SEQUENCE ... NO CACHE` | 2 | 3 | none |
| `INT IDENTITY` with `IDENTITY_CACHE = OFF` | 3 | **4** | none |
**Consequence of being wrong: any number a human reads or a regulator counts, an invoice number, a
ticket number, an order reference, develops thousand-wide holes, unrecoverably.** Nothing errors.
```sql
-- The remembered remedy from a non-PaaS engine. Refused here.
DBCC TRACEON (272, -1); -- Msg 40518, DBCC command 'traceon' is not supported
-- The lever that works. It survives a restart and costs a log write per value.
ALTER DATABASE SCOPED CONFIGURATION SET IDENTITY_CACHE = OFF;
-- A human-facing number gets its own sequence, and START WITH is not optional:
-- omitted, the first value is -9223372036854775808.
CREATE SEQUENCE dbo.invoice_no AS BIGINT START WITH 1 INCREMENT BY 1 NO CACHE;
```
A surrogate key may be `IDENTITY`: gaps in a key nobody reads are free, and the cache is why
inserts are fast. A number a person reads is not a surrogate key.
## 5. A unique index accepts exactly one NULL
This one arrives from PostgreSQL habit, where every NULL is distinct.
```sql
CREATE UNIQUE INDEX ux_code ON dbo.accounts (code);
INSERT INTO dbo.accounts (code) VALUES (NULL); -- 1 row
INSERT INTO dbo.accounts (code) VALUES (NULL);
-- Msg 2601, Cannot insert duplicate key row ... The duplicate key value is (<NULL>).
-- Msg 2627 instead, where the constraint is a unique constraint rather than an index.
-- The fix, verified to accept many NULL rows:
DROP INDEX ux_code ON dbo.accounts;
CREATE UNIQUE INDEX ux_code ON dbo.accounts (code) WHERE code IS NOT NULL;
```
**Consequence of being wrong: an optional unique field, an external account id, a nullable slug,
works for the first row that leaves it empty and rejects the second.** A model with two or three
optional unique fields is unusable here and correct on other engines. The session creating a
filtered index, and every session writing to the table, needs `QUOTED_IDENTIFIER ON`, else
`Msg 1934`. Drivers set it; a command line client may not.
## 6. Where the json and vector types belong
Both are storage decisions, not key decisions.
| | `json` | `vector(n)` |
|---|---|---|
| Index key column | no, `Msg 1978` | no, `Msg 1978` |
| Primary key | no | no, `Msg 1919` |
| `INCLUDE` column | yes, verified | route to `vector-search-azure-sql` |
| Compared with `=` | no, `Msg 402` against `nvarchar` | route to `vector-search-azure-sql` |
| Ceiling | 2 GB | 1998 dimensions, `Msg 2717` above it |
A `json` column is not a filter. Promote anything a query filters on **every time** into a real
column:
```sql
ALTER TABLE dbo.doc ADD tenant AS CAST(JSON_VALUE(payload, '$.tenant') AS NVARCHAR(64)) PERSISTED;
CREATE INDEX ix_doc_tenant ON dbo.doc (tenant);
```
A `vector` column is a payload beside the key, never part of it. A `vector(1536)` occupies 6152 of
the 8060 bytes in a row, so keep the table narrow: put the embedding next to an integer key.
## 7. Two boundaries a design cannot cross here
```sql
-- No foreign key reaches another database, even one on the same server.
ALTER TABLE dbo.local_child ADD CONSTRAINT fk_x FOREIGN KEY (parent_id)
REFERENCES otherdb.dbo.some_parent (id);
-- Msg 40515, Reference to database and/or server name in 'otherdb.dbo.some_parent'
-- is not supported in this version of SQL Server.
-- There is one filegroup, so a partition scheme maps everything to it.
SELECT name, type_desc FROM sys.filegroups; -- PRIMARY, and nothing else
CREATE PARTITION SCHEME ps AS PARTITION pf ALL TO ([PRIMARY]);
```
A shared-lookup-database design has no referential integrity here. Partitioning buys
manageability, never storage separation.
## Check it worked
Run the DDL through `sqlcmd` with `-m-1`, because message 1945 is **severity 10** and `-b` alone
does not report severity 10 at all, so a migration that emitted it still exits 0:
**`-m-1` is an ODBC `sqlcmd` instruction**, meaning the 18.x build from `mssql-tools18` or the
Microsoft command line utilities. Measured 2026-09-05, go-sqlcmd 1.10.0, the 1.x build
`brew install sqlcmd` and `winget install sqlcmd` install, prints no `Msg` header on a severity 10
message at any `-m` value, so on that build the `grep` below finds no message 1945 to report.
Query `sys.messages` or run the statement through the ODBC build when the number matters.
```bash
export SQLCMDPASSWORD='<password>'
sqlcmd -S <server-name>.database.windows.net,1433 -d <database> -U <user> -C -b -m-1 \
-i schema.sql -o schema.out
grep -nE 'Warning!|^Msg ' schema.out
```
Expect no output from `grep`. A `Warning!` line naming a key length is message 1945 and predicts
`Msg 1946` on a production insert.
Then audit the schema the engine actually has, as `schema-audit.sql`:
```sql
-- 1. Index keys over budget. Expect zero rows.
SELECT OBJECT_NAME(i.object_id) AS table_name, i.name AS index_name, i.type_desc,
SUM(CASE WHEN c.max_length = -1 THEN 0 ELSE c.max_length END) AS key_bytes,
CASE WHEN i.type = 1 THEN 900 ELSE 1700 END AS budget
FROM sys.indexes AS i
JOIN sys.index_columns AS ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
AND ic.is_included_column = 0
JOIN sys.columns AS c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE i.type IN (1, 2) AND OBJECTPROPERTY(i.object_id, 'IsUserTable') = 1
GROUP BY i.object_id, i.name, i.type, i.type_desc
HAVING SUM(CASE WHEN c.max_length = -1 THEN 0 ELSE c.max_length END)
> CASE WHEN i.type = 1 THEN 900 ELSE 1700 END;
-- 2. Unique indexes over a nullable column with no filter: one NULL row each. Expect zero.
SELECT OBJECT_NAME(i.object_id) AS table_name, i.name AS index_name, c.name AS column_name
FROM sys.indexes AS i
JOIN sys.index_columns AS ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
AND ic.is_included_column = 0
JOIN sys.columns AS c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE i.is_unique = 1 AND i.has_filter = 0 AND c.is_nullable = 1;
-- 3. VARCHAR or CHAR key columns on a SQL collation: the lost seek. Expect zero rows.
SELECT DISTINCT OBJECT_NAME(c.object_id) AS table_name, c.name AS col, c.collation_name
FROM sys.columns AS c
JOIN sys.index_columns AS ic ON ic.object_id = c.object_id AND ic.column_id = c.column_id
AND ic.is_included_column = 0
JOIN sys.types AS t ON t.user_type_id = c.user_type_id
WHERE t.name IN ('varchar', 'char') AND c.collation_name LIKE 'SQL[_]%';
-- 4. Sequences left on the type minimum because START WITH was omitted. Expect zero rows.
SELECT name, CAST(start_value AS BIGINT) AS start_value
FROM sys.sequences WHERE CAST(start_value AS BIGINT) < 0;
-- 5. The two settings the design assumed. Read them, do not assume them.
SELECT DATABASEPROPERTYEX(DB_NAME(), 'Collation') AS db_collation,
(SELECT value FROM sys.database_scoped_configurations
WHERE name = 'IDENTITY_CACHE') AS identity_cache;
```
```bash
sqlcmd -S <server-name>.database.windows.net,1433 -d <database> -U <user> -C -W \
-i schema-audit.sql -o schema-audit.out
```
Checks 1 to 4 return no rows in a clean schema. Check 5 is the one you read rather than pass:
`identity_cache` of `0` means gaps are off, `1` means an `INT IDENTITY` will jump by about a
thousand across the next reconfiguration.
Last, confirm the seek survived on one real lookup:
```sql
SET STATISTICS IO ON;
DECLARE @p NVARCHAR(320) = N'u9999@example.com';
SELECT id FROM dbo.users WHERE email = @p;
SET STATISTICS IO OFF;
```
Expect single-digit logical reads that do not grow with the table. Reads in the tens over twenty
thousand rows is the implicit conversion in section 3, not a cold cache.
## Do not
- Do not read message 1945 as informational. It is the only notice given, and severity 10 means
neither `sqlcmd -b` nor most drivers raise it.
- Do not size a key column in characters. The limit is bytes, and Unicode doubles it.
- Do not use `VARCHAR` under the default collation for anything an application looks up by value.
- Do not add `LOWER()` or `UPPER()` around a column to fix a collation problem. That costs the
index too, and it is `t-sql-correctness`.
- Do not reach for trace flag 272. It is rejected with `Msg 40518`. The lever is
`IDENTITY_CACHE = OFF`.
- Do not put an invoice or ticket number, or anything an auditor reads, on a bare `IDENTITY`.
- Do not create a `SEQUENCE` without `START WITH`.
- Do not give a model two or three optional unique columns and expect PostgreSQL NULL semantics.
- Do not store a value in a `json` document and then filter on it. Promote it.
- Do not add a clustered index only because you believe this engine demands one. A heap was
created and took rows on 2026-08-28.
## References
- Open [the measured statements](references/key-bytes-collation-and-identity-gaps.md) when you need
the statement behind a number above, the two query plans, or the reproduction steps.
- Read [Maximum capacity specifications](https://learn.microsoft.com/sql/sql-server/maximum-capacity-specifications-for-sql-server)
before designing a wide composite key.
- Read [Collation and Unicode support](https://learn.microsoft.com/sql/relational-databases/collations/collation-and-unicode-support)
before choosing a database collation: SQL against Windows collations, and UTF-8.
- Read [json data type](https://learn.microsoft.com/sql/t-sql/data-types/json-data-type) before
putting a document in a column, for the index restrictions and the size limits.
- Read [Database scoped configurations](https://learn.microsoft.com/sql/t-sql/statements/alter-database-scoped-configuration-transact-sql)
before setting `IDENTITY_CACHE`, and
[T-SQL differences](https://learn.microsoft.com/azure/azure-sql/database/transact-sql-tsql-differences-sql-server)
when a statement is refused with 40510, 40515, 40517 or 40518.
- `ef-core-azure-sql` and `sqlalchemy-azure-sql`: how each mapper reaches these lengths and the
default it lands on. The other mapper skills are listed in `skill.spec.jsonc`.
- `t-sql-correctness` for query-level correctness, `t-sql-json-and-openjson` for querying a
document column, `vector-search-azure-sql` for the vector type and its indexing.
在 GitHub 查看