Table storage — data model limits, Entra ID access from TypeScript, entity group transactions and Table error codes
Working with Azure Table storage from Node.js — the entity and key limits (255 properties, 1 MiB entity, 1,024-character keys, forbidden key characters, table name rules), access with Storage Table Data roles and @azure/data-tables 13.3 with DefaultAzureCredential, safe filters with odata``, entity group transactions (one partition, 100 actions, 4 MiB, failed-index message), where the SDK puts the error code, and the Table service error codes with fixes.
markdownreferencev14GW8-NNPEstage: buildtrust: officialservice: storageinterface: sdkAzure Table storage@azure/data-tablesTableClientDefaultAzureCredential Table storageStorage Table Data ContributorStorage Table Data Readerodata filtersubmitTransactionTableTransactionentity group transaction100 operations change set4 MiB batchEntityAlreadyExistsThe specified entity already exists.EntityNotFoundThe specified entity does not exist.TableNotFoundThe table specified does not exist.TableAlreadyExistsTableBeingDeletedThe specified table is being deleted.UpdateConditionNotSatisfiedThe update condition specified in the request was not satisfied.EntityTooLargeThe entity is larger than the maximum size permitted.PropertyValueTooLargeTooManyPropertiesInvalidDuplicateRowInvalidInputOutOfRangeInputPropertyNameInvalidPropertiesNeedValueInvalidValueTypePartitionKey RowKey forbidden characterstable name rules1 MiB entity252 custom properties
Table storage
Azure Table storage is the key-value table service inside a storage account (https://<acct>.table.core.windows.net). Cosmos DB for Table speaks the same API with other limits; this page covers Table storage.
Limits of the data model (fixed, documented)
| Item | Limit |
|---|---|
| Properties per entity | 255, including PartitionKey, RowKey and Timestamp: 252 of your own |
| Entity size | 1 MiB for all properties combined |
PartitionKey, RowKey | Strings up to 1,024 characters. Not allowed: /, \, #, ?, and control characters U+0000–U+001F and U+007F–U+009F |
| String and binary values | 64 KiB each (a string holds about 32K characters, UTF-16) |
| Property names | Case-sensitive, up to 255 characters, C# identifier rules, no - |
| Table names | ^[A-Za-z][A-Za-z0-9]{2,62}$, case-insensitive, unique per account; some names are reserved (tables) |
Timestamp is set by the server and ignored on writes. Queries are fast only on PartitionKey (and RowKey within it); any other filter scans.
Access from TypeScript with Entra ID
Grant Storage Table Data Contributor (or Reader) on the account or the table; Owner and Contributor don't read data (reference/storage/access-models.md). Then:
import { DefaultAzureCredential } from "@azure/identity";
import { TableClient, odata, type TransactionAction } from "@azure/data-tables";
import { RestError } from "@azure/core-rest-pipeline";
// Needs Storage Table Data Contributor on the account or table.
const client = new TableClient(
`https://${process.env.STORAGE_ACCOUNT}.table.core.windows.net`,
"orders",
new DefaultAzureCredential(),
);
// The service code is in details.errorCode (single operations) or e.code (transactions).
export function tableErrorCode(e: unknown): string | undefined {
if (!(e instanceof RestError)) return undefined;
const d = e.details as { errorCode?: string } | undefined;
return d?.errorCode ?? e.code;
}
export async function main(): Promise<void> {
await client.createTable(); // no error if it already exists
await client.upsertEntity({ partitionKey: "cust-42", rowKey: "order-1001", total: 12.5 }, "Merge");
// Build filters with odata`` so values are quoted and escaped.
const customer = "cust-42";
for await (const e of client.listEntities<{ total: number }>({
queryOptions: { filter: odata`PartitionKey eq ${customer} and total gt ${10}` },
})) {
console.log(e.rowKey, e.total);
}
// A transaction: one partition, at most 100 actions, at most 4 MiB, each entity once.
const actions: TransactionAction[] = [];
for (let i = 0; i < 250; i++) {
actions.push(["upsert", { partitionKey: customer, rowKey: `line-${i}`, qty: i }]);
}
for (let i = 0; i < actions.length; i += 100) {
try {
await client.submitTransaction(actions.slice(i, i + 100));
} catch (e) {
// The message starts with the zero-based index of the failed action, e.g. "3:…"; the whole batch rolled back.
console.error(tableErrorCode(e), (e as Error).message);
throw e;
}
}
try {
await client.createEntity({ partitionKey: customer, rowKey: "order-1001" });
} catch (e) {
if (tableErrorCode(e) !== "EntityAlreadyExists") throw e;
}
}
Where the code is: for a single operation, @azure/data-tables 13.3.2 leaves RestError.code undefined and puts the service code in e.details.errorCode (and e.details.odataError.code); for a failed transaction it sets e.code. Match on both, as above.
Transactions: all actions share one PartitionKey; at most 100 actions and 4 MiB; an entity appears once; actions are create, update, upsert or delete, not queries. They're atomic: one failure rolls back the rest, and its message starts with the failed action's zero-based index.
Error codes
Messages are the REST table's wording; the service appends RequestId: and Time: lines.
| Status, code, message | Cause → fix | Check |
|---|---|---|
409 EntityAlreadyExists The specified entity already exists. | createEntity on an existing key. Use upsertEntity, or treat as success when idempotent | Get the entity |
404 EntityNotFound The specified entity does not exist. | Wrong key (keys are case-sensitive), or deleted. getEntity throws it; check before updating | listEntities with the PartitionKey filter |
404 TableNotFound The table specified does not exist. | Create the table first (createTable is idempotent) or fix the name | az storage table exists -n <t> --account-name <a> --auth-mode login |
409 TableBeingDeleted The specified table is being deleted. | Recreating a just-deleted name: wait and retry, or use another name | Retry with backoff |
412 UpdateConditionNotSatisfied The update condition specified in the request was not satisfied. | The entity's ETag changed since you read it (optimistic concurrency). Re-read and retry, or update with etag: "*" to overwrite on purpose | Compare etag |
400 EntityTooLarge / PropertyValueTooLarge / TooManyProperties | Over 1 MiB, a value over 64 KiB, or over 252 custom properties. Move large values to a blob and store its name | Serialized size |
400 InvalidDuplicateRow A command with RowKey '{0}' is already present in the batch. An entity can appear only once in a batch. | De-duplicate actions before submitTransaction | — |
400 InvalidInput The batch request operation exceeds the maximum 100 changes per change set. | Split into chunks of 100, per partition | Action count |
400 InvalidInput (other messages) | Conflicting If-Match / If-None-Match headers, or an invalid ETag (the documented messages); the hub's reading is that a forbidden key character can surface here too | Key values against the forbidden list |
400 OutOfRangeInput The '{0}' parameter of value '{1}' is out of range. | The hub's reading: often a table name or key that breaks the rules above | Name regex above |
400 PropertyNameInvalid / PropertyNameTooLong / DuplicatePropertiesSpecified / PropertiesNeedValue / InvalidValueType | Fix the entity's property names, duplicates, missing values or types | — |
403 AuthorizationPermissionMismatch and other 401/403 | Missing Table data role, network rules, or shared key disabled | reference/storage/access-errors.md |