Acumatica's multi-tenancy is often confused with multi-company. Multi-company is multiple companies or branches sharing one tenant's database. True multi-tenancy is different: one deployed application instance — one IIS app pool, one set of compiled customization DLLs — serving several completely isolated Tenants, each with its own database, managed through the Tenants screen. That distinction matters the moment you write code, because a customization package is not deployed once per tenant. It is deployed once, and every tenant on that instance loads the same assembly into the same process.
What CompanyID actually does
Acumatica's multitenancy is implemented per table, not per database and not as some platform-wide switch you flip once. A table becomes tenant-isolated when it has a CompanyID column that is part of its primary key and part of every index on the table. Every query the platform generates against that table implicitly filters and joins on CompanyID, so tenant A's rows are never visible in a query run under tenant B's session — the isolation is enforced at the schema level, not by application code remembering to add a WHERE clause.
The consequence that catches people building a custom DAC: if you add a table without a CompanyID column, there is no tenant boundary on it at all. Every row you insert is visible to every tenant sharing that database instance, full stop. This isn't a permissions gap that shows up as an access-denied error — it's silent. The table works fine in testing, because you were only ever logged in as one tenant. The leak only becomes visible once a second tenant goes live on the same instance and someone notices data that isn't theirs.
One instance, shared code path
PXCache, PXLongOperation, and the standard graph lifecycle already know how to apply CompanyID scoping for any table that has the column — a query built the normal way through a graph's data views will only ever touch the current session's tenant, automatically. Most bugs in multi-tenant deployments do not come from that layer. They come from custom tables missing CompanyID, or from code that steps outside the ORM entirely (raw SQL, static caches) and loses the scoping the platform would otherwise have given it for free.
Users with cross-tenant access can switch tenants from the login screen without re-authenticating against a different URL or instance. If your customization keeps any assumption that "the current user only ever sees one company," that assumption breaks the first time an admin account with tenant-switching rights touches the screen.
Static state is the real risk
The customization DLL is loaded once per application instance and shared in memory by every tenant using it. Any static field, singleton, or process-wide cache you add is shared the same way. Cache a price list, a feature flag, or a lookup result in a static Dictionary keyed only by item ID or customer ID, and tenant A's data can leak into tenant B's screen — silently, and usually only noticed after a support ticket from the wrong customer.
A static field is scoped to the app domain, not to the tenant. If you must cache in-process, key every entry by tenant ID as well as whatever business key you were already using — or better, avoid static caching of tenant data entirely and let PXCache handle it, since that is already scoped correctly.
// Unsafe: shared across every tenant on this instance
private static readonly Dictionary<string, decimal> _priceCache = new();
// Safer: key includes tenant, or avoid the static cache altogether
private static readonly ConcurrentDictionary<(int TenantId, string ItemCode), decimal> _priceCache = new();
public class SOOrderEntryExt : PXGraphExtension<SOOrderEntry>
{
protected virtual decimal GetCachedPrice(string itemCode)
{
int tenantId = PXAccess.GetParentBAccountID() ?? 0;
var key = (tenantId, itemCode);
if (_priceCache.TryGetValue(key, out var price))
return price;
price = LookupPrice(itemCode); // resolve normally, then cache scoped to tenant
_priceCache[key] = price;
return price;
}
}
Integration config cannot be hardcoded
Custom integrations, scheduled processing built on Automation Schedules, and any code that calls out to an external API tend to accumulate hardcoded endpoints, API keys, or account IDs during development, when there is only one tenant to test against. That code breaks as soon as it runs on a shared instance serving multiple customers, each of whom needs their own credentials and endpoint. Store integration settings in a per-tenant settings DAC and resolve them at runtime instead of baking them into the assembly.
The same applies to hardcoded record IDs — a branch ID, a warehouse ID, a numbering sequence — that happened to exist in your dev tenant. They will not exist, or will mean something different, in every other tenant sharing the instance.
The publish-time gotcha: one action, two scopes
This is the part that catches people who otherwise understand everything above. When you publish a customization project on a multitenant site, you pick which tenants to target — and it looks like that selection controls the whole blast radius. It does not. Website-level changes in the project — a new screen, a new field on an existing DAC, a new business event, anything that is schema or UI rather than data — apply to every tenant on that instance the moment you publish, regardless of which tenants you selected. Tenant-specific changes — configuration values, data records, anything scoped to a tenant's own database — apply only to the tenants you targeted.
So a single publish action can be simultaneously site-wide and tenant-scoped, depending on what part of the project it touches. Someone who publishes "to tenant A only" and assumes that fully isolates the release is right about the data and wrong about the schema and screens — those went everywhere. All tenants also inherit their initial configuration and system data from the System tenant, and System tenant data stays visible to every tenant, which is a second, related way changes propagate further than a single-tenant publish target suggests.
| Concept | Multi-Tenant | Multi-Company / Branch |
|---|---|---|
| Isolation boundary | Separate database per tenant | Shared database, separated by company/branch ID |
| Code deployment | One shared DLL for all tenants on the instance | One DLL, one database — no cross-tenant sharing concern |
| Static state risk | High — leaks across tenants if not scoped | Low — same database context throughout |
| Managed via | Tenants screen, login-time tenant selection | Company/branch selection within a session |
Testing across tenants
A customization that passes every test in a single-tenant sandbox can still break in production the first time it runs on a shared instance with real tenant switching. Before publishing anything wider than one tenant, walk the diff for static fields, hardcoded IDs, and any assumption that "there is only one company in this database." That review takes minutes and catches the class of bug that otherwise surfaces as a data-leakage incident weeks later.
Wrapping up
Most multi-tenant mistakes trace back to one wrong mental model: treating "publish to one tenant" as a guarantee that the blast radius stopped there. It doesn't, once your project touches the schema or UI. Add CompanyID to every custom table's primary key and indexes so tenant isolation actually holds, never cache tenant data in a static field without a tenant key in it, and before you publish, know which part of your change is website-wide (schema, screens, code) and which part is tenant-scoped (data, config) — because a single publish action can be both at once. If you are stuck on something specific, reach out or keep reading through the rest of the Acumatica blog.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.