RE:NODE

SQL Server Express limits: 10 GB cap and workarounds

By RE:NODE Team
17 min read


SQL Server Express has three ceilings you can design around and one missing component you have to replace. The ceilings are 10 GB of data per database, about 1.4 GB of buffer pool per instance, and the lesser of one socket or four cores. The missing component is SQL Server Agent, the scheduler that most tutorials assume you have. All four have workable answers: keep each database lean with compression, archiving and separate databases, size the server for the cache you will actually get, and move scheduled work out of the engine into cron, a Task Scheduler entry, your application or the host's own schedule. This post is the workaround half of the story. For what the limits are and when to give up on Express, read SQL Server Express hosting: limits and when it is enough first; this one assumes you have decided to stay.

Which limit you will hit first#

On most real workloads the order is: disk, then memory, then the missing Agent, then CPU, and the 10 GB cap comes last or never. People fear the data cap because it is the number in the headline, but a typical line-of-business database holds a few hundred megabytes to a couple of gigabytes after years of use. The things that actually bite earlier are a transaction log nobody backs up, a table of audit rows with no retention, and a nightly job that never ran because there was no Agent to run it.

Measure before you worry. Two queries tell you where you stand: the first for the data files of the database you are connected to, the second for how much of the cache each database is using.

sql
SELECT name, type_desc,       size * 8 / 1024 AS size_mb,       FILEPROPERTY(name, 'SpaceUsed') * 8 / 1024 AS used_mbFROM sys.database_files;SELECT DB_NAME(database_id) AS db, COUNT(*) * 8 / 1024 AS cached_mbFROM sys.dm_os_buffer_descriptorsGROUP BY database_idORDER BY cached_mb DESC;

The cap is enforced on the allocated size of the data files, so size_mb is the number to compare with 10,240, not used_mb. Log files do not count towards it. If size_mb is under half the cap and growing slowly, you have years; spend your effort on backups and scheduling instead.

Staying under 10 GB#

When a data file needs to grow past the cap, the growth is refused and inserts that need new pages fail, while reads carry on. There is no warning in between, so the workarounds are about never getting close. In order of effort:

Find what is actually big

Run a per-table size query before deciding anything. The top three tables are nearly always the whole story.

sql
SELECT TOP (10)       s.name + '.' + t.name AS table_name,       SUM(p.reserved_page_count) * 8 / 1024 AS reserved_mb,       SUM(CASE WHEN p.index_id IN (0, 1) THEN p.row_count END) AS row_countFROM sys.dm_db_partition_stats AS pJOIN sys.tables AS t ON t.object_id = p.object_idJOIN sys.schemas AS s ON s.schema_id = t.schema_idGROUP BY s.name, t.nameORDER BY reserved_mb DESC;

Compare reserved size with row count. A table of ten million narrow rows at 2 GB is normal. A table of 40,000 rows at 3 GB is storing blobs, and blobs are the one thing you should not keep in a size-capped database.

Compress tables and indexes

Row and page compression work on Express (they have been available in every edition since SQL Server 2016 SP1). Page compression often halves a large, repetitive table such as an audit log, at some extra CPU on writes and reads. Ask the engine to estimate the saving before you commit to a rebuild:

sql
EXEC sp_estimate_data_compression_savings     @schema_name = 'dbo', @object_name = 'AuditLog',     @index_id = NULL, @partition_number = NULL,     @data_compression = 'PAGE';ALTER TABLE dbo.AuditLog REBUILD WITH (DATA_COMPRESSION = PAGE);

The rebuild needs working space of roughly the table's size, and it grows the log, so do it when there is headroom on both and take a backup first. For wide tables that are mostly read, a clustered columnstore index compresses far more than page compression, but Express caps the columnstore cache at 352 MB and columnstore suits analytics-style scans, not point lookups, so test with your own queries.

For individual large text or JSON columns, COMPRESS() and DECOMPRESS() apply GZIP inside T-SQL and store the result in a varbinary(max) column. It saves space on big payloads; the price is that you can no longer index or search the column directly.

Move blobs out

Images, PDFs and uploaded documents stored in varbinary(max) fill a 10 GB database faster than anything else, and every one of them also passes through your 1.4 GB buffer pool, evicting pages that matter. Store the file in object storage or on disk, and keep only a key, a size and a content type in the table. RE:NODE sells S3-compatible object storage as a separate line if you want it next to the database; any S3 endpoint works the same way.

Archive in batches

Old rows that must be kept but are rarely read can move to a second database on the same instance, which has its own 10 GB. T-SQL cannot do a partition switch across databases, so move in small batches that delete from the source and insert into the archive in one statement:

sql
WHILE 1 = 1BEGIN    DELETE TOP (5000) FROM app.dbo.AuditLog    OUTPUT deleted.* INTO archive.dbo.AuditLog    WHERE CreatedAt < DATEADD(MONTH, -18, SYSUTCDATETIME());    IF @@ROWCOUNT = 0 BREAK;END

OUTPUT ... INTO needs the target table to have matching columns and no enabled triggers or foreign keys on it. Small batches keep each transaction short, so the log does not balloon and locks are released quickly. Run it from a scheduled job (see below), not by hand.

Delete what has no value

Expired sessions, password-reset tokens, soft-deleted rows older than your retention policy, and verbose request logs have no reason to live in the database for ever. Decide a retention period for each table, write one purge statement per table, and schedule them. This is the cheapest workaround and the one most databases never apply.

Remove unused indexes

Indexes count towards the data size. The dynamic management view sys.dm_db_index_usage_stats shows reads and writes per index since the last restart. An index with many writes and no reads is pure cost. Only trust it after the server has run through a full business cycle (a month-end report may be the only user of an index), because the counters reset on restart.

Split by purpose

One database per application, and a separate reporting or archive database for data that grows without bound, is good design on any edition and it multiplies your headroom on Express. The cap is per database, not per instance. Cross-database queries on one instance work with three-part names (archive.dbo.AuditLog), so splitting rarely means rewriting queries.

Shrinking, once

After a large purge the file stays large. DBCC SHRINKFILE returns the space but fragments indexes badly, so run it once after a major cleanup, rebuild the indexes that matter, and never schedule it. Because the cap applies to allocated size, a half-empty 9.8 GB file is still close to the ceiling until you shrink it.

Disk is a limit too, and on a small plan it comes first#

The 10 GB cap is a licence rule; the disk is a physical one, and on hosted plans it is usually the smaller of the two. A 10 GB database needs room for its log, for tempdb, for the backups you keep on the same volume while you copy them away, and for the operating system's own files. On RE:NODE the SQL Server line is Express Starter (2 GB memory, 10 GB disk, 1 vCPU) up to Express Max (6 GB memory, 80 GB disk, 4 vCPU), from $6 a month. The arithmetic is plain: on a 10 GB disk you will never reach a 10 GB database, and on 80 GB you can run several databases near the cap.

So choose the plan by disk first. A rough budget for one database that you expect to be N GB at its largest is: N for data, N/4 to N/2 for the log, one full backup file (about N, since Express does not compress backups) while it sits on the server, and a few GB for tempdb and headroom. The second backup copy lives off the machine, which is the point; see the scheduling section.

Living with 1.4 GB of buffer pool#

The memory cap applies to the buffer pool, the cache of data pages. The process as a whole uses more, because plans, connections and sorts are outside the cap. In practice this means two things.

First, a server with more than about 4 GB of RAM does not help the engine, because the cache stops at 1,410 MB. Buy memory only up to the point where the buffer pool can fill and the operating system and engine have room; beyond that, buy disk. The RE:NODE tiers reflect this: the step from Express Pro to Express Max (4 GB to 6 GB memory) is mainly about disk and the full four cores.

Second, a working set bigger than 1.4 GB is read from disk more often. NVMe storage, which every RE:NODE plan uses, makes that much less painful than it was on spinning disks, but you can still reduce the reads:

  • Narrower rows and indexes. A covering index that holds only the columns a hot query needs keeps more useful rows per cached page. Compression packs more rows into each page, which stretches the cache as well as the disk.
  • Fewer, better indexes. Every index you maintain competes for the same 1.4 GB. Indexes and execution plans explains how to read a plan and drop what does not help.
  • **No SELECT *.** Fetching wide rows you do not use drags blobs and rarely-needed columns through the cache.
  • Find the expensive queries. A handful of queries usually accounts for most of the page reads. Query Store and slow queries shows how to find them on Express, where Query Store is available.
  • Cache in the application. Reference data that never changes belongs in the application's memory or in Valkey, not read from SQL Server on every request.

Cap the instance so nothing else starves

SQL Server on a default install tries to use all the memory it is given, up to its edition cap, and Express stops at 1,410 MB for the buffer pool. If the container or machine has little memory beyond that, set an explicit ceiling so the operating system keeps some for itself:

sql
EXEC sp_configure 'show advanced options', 1;RECONFIGURE;EXEC sp_configure 'max server memory (MB)', 1536;RECONFIGURE;

Whether you can change this depends on how your host runs the instance and which login you have, so check before relying on it. On RE:NODE, the memory limit of a plan is enforced by the container: at the limit the kernel stops the container and it restarts clean instead of swapping. That behaviour is a feature for stability and a reason to leave headroom. Pick a plan whose memory is comfortably above what the SQL Server process needs, and watch the memory graph on the Console tab rather than guessing.

The four-core limit#

Express uses the lesser of one socket or four cores, so a host that gives you eight does not make it faster. The workaround is to use the cores well rather than to want more of them:

  • Fix the worst queries first. A missing index that turns a seek into a scan on every request costs more CPU than any amount of tuning elsewhere.
  • Keep parallelism sane. Parallel plans on a four-core instance can crowd out other work. The server-wide max degree of parallelism setting (EXEC sp_configure 'max degree of parallelism', 2; after enabling advanced options) is a common choice on small servers, but test it against your workload rather than copying a number.
  • Move reporting off the busy hours. Heavy aggregation belongs in the quiet part of the day, which is another reason to have a scheduler.
  • Offload reads that are not queries. Full-text style searching, caching and queueing are better handled by a different tool than by making SQL Server do everything.

On RE:NODE the CPU of a plan is a hard throttle to the share you bought, so on Express Starter you have one vCPU in all, not four. A server sitting at 100% CPU is slow, not broken, and it is never suspended for it. If the CPU graph pins on a busy hour, that is your sign to tune queries or step up a tier.

No SQL Server Agent: what to use instead#

The Agent job step is a command, a schedule and an exit status. Any scheduler that can run sqlcmd has the same three. What you give up is the built-in history, alerts and mail, and you replace those with your own logging. The options, from least to most effort:

The host's own schedule, for backups

On RE:NODE every server has a Schedules tab: a cron expression, then ordered tasks with delays, where a task is a console command, a backup or a power action. A backup task makes a nightly copy of the server without a script of yours to keep alive. SQL Server plans carry 1 to 4 backup slots; backups are stored off the machine they protect, can be locked against rotation, downloaded and restored with a button. This covers the most important job Agent normally does. Do not read it as running arbitrary T-SQL from the panel; for that, use one of the next options. Cron expressions explained covers the syntax and scheduled tasks worth having suggests what to put in one.

cron plus sqlcmd

On any Linux machine you control (a small VDS, a CI runner, an application server), a crontab line runs a script file against the database. Keep the password out of the command line by putting it in the SQLCMDPASSWORD environment variable:

bash
$ export SQLCMDPASSWORD='the-generated-password'$ sqlcmd -S db.example.net,14330 -U sa -d app -C -b -i /opt/jobs/purge.sql

-S takes host,port with a comma, -C trusts the server certificate (needed with a self-signed one, and acceptable only if you know the host), -b makes sqlcmd return a non-zero exit code when the script fails, and -i names the script. The exit code is what lets cron or a wrapper tell you something went wrong. sqlcmd and bcp covers the flags and the two different programs that share the name.

A crontab entry for a nightly purge at 03:15, with output appended to a log file:

code
15 3 * * * /opt/jobs/run-sql.sh purge.sql >> /var/log/sqljobs.log 2>&1

Wrap the call in a small shell script that exits non-zero on failure and sends you a message (an email, a webhook to a chat channel) so that a silent failure becomes a visible one. This is the replacement for Agent's alerts and Database Mail, both of which Express lacks.

Windows Task Scheduler

If your application runs on Windows, a scheduled task that runs sqlcmd -S ... -i script.sql does the same job. Set it to run whether or not the user is logged on, store the credentials in the task rather than in the script, and set "stop the task if it runs longer than" to something sensible, so a hung connection does not block tomorrow's run.

Inside your application

A hosted service in .NET, a Hangfire or Quartz.NET job, a Node cron library or a background worker in any language can run maintenance T-SQL on a schedule, and it has one advantage: it already has a connection string and a logger. .NET with PostgreSQL, MySQL or SQL Server covers the connection side. The weakness is that if the application is down, so is the job, and a job that runs in every instance of a scaled-out app runs several times. Use it for purging and statistics, but do not make it the only thing that takes backups.

What not to do

Do not start a connection that sits in a WAITFOR DELAY loop in a query window or a long-running procedure to imitate a scheduler. It holds a connection and a session for ever, dies silently when the server restarts, and tells you nothing when it fails. The same goes for a trigger that does heavy work on every insert in the hope of avoiding a job: it moves the cost into your users' requests.

Replacing the jobs Agent usually ran#

Most Agent installations run four kinds of job. Each has a straightforward equivalent.

Agent jobReplacement on Express
Nightly full backupHost schedule backup, or BACKUP DATABASE via cron
Log backups every 15-60 minutesBACKUP LOG script from cron, or use simple recovery
Index and statistics maintenanceWeekly script, or a maintained script collection
Purge, archive, reporting tablesBatched T-SQL from cron or the app

For native backups, a script file as small as this does the work, and the next step is to copy the result somewhere else:

sql
BACKUP DATABASE [app] TO DISK = N'/var/opt/mssql/backup/app.bak'    WITH INIT, CHECKSUM, STATS = 10;RESTORE VERIFYONLY FROM DISK = N'/var/opt/mssql/backup/app.bak' WITH CHECKSUM;

The path is the default backup directory on a Linux install; a hosted server may differ. A .bak on the same disk as the database protects you from a typo, not from a lost disk. SQL Server backup and restore goes through restoring, WITH MOVE, and orphaned users. Because Express cannot compress backups itself, compress the file after it is written (gzip or zstd in the same script) before you ship it off the machine.

Log backups matter only if the database is in the full recovery model. If you do not need point-in-time restore, ALTER DATABASE [app] SET RECOVERY SIMPLE; removes that job entirely and prevents the runaway log described in recovery models and log growth. If you do need it, schedule BACKUP LOG and test a restore to a point in time, because a log chain you have never restored is a hypothesis.

For index and statistics maintenance, do less than the old advice says. Auto-update statistics is on by default and covers small databases. Fragmentation matters little on NVMe; rebuild or reorganise an index when you can show it slowing a particular query. If you want a ready-made routine, the free, widely used maintenance solution by Ola Hallengren is plain T-SQL and works on Express as long as something external calls it, which is the whole point of this section.

Scheduling in practice: a nightly job end to end#

Put together, a sensible nightly routine on an Express database looks like this:

  1. 02:00 - the host schedule runs the platform backup for the server (or your script runs BACKUP DATABASE and copies the file away).
  2. 03:15 - a cron job on a machine you control runs the purge and archive script with sqlcmd -b.
  3. 03:45 - the same machine runs sp_updatestats if you have seen stale statistics, and the size query that writes the database size to a log or a metric.
  4. Weekly - a restore of the latest backup to a scratch database, then DBCC CHECKDB against the restored copy rather than the live one.
  5. Monthly - read the size trend and decide whether to compress, archive or move up a tier.

Stagger the jobs so a long purge never overlaps the backup. On a four-core, 1.4 GB-cache instance, two heavy jobs at once will make the application feel slow for the length of the overlap.

Upgrading the instance without losing the data#

When the workarounds stop being enough, the move to a larger edition or another host is a backup and restore: an Express .bak restores onto Standard or Enterprise of the same or a newer version without conversion. Plan the move before the cap forces it. Migrating a database to SQL Server hosting covers the version rules in the other direction, and which database should I use compares engines if the real answer is a different one. Changing plan on RE:NODE does not rebuild the server, so stepping from a smaller Express tier to a larger one for disk is not a migration.

FAQ#

What is the quickest way to get under the 10 GB limit?

Find the largest tables, delete or archive rows you no longer need in batches of a few thousand, move any stored files to object storage, then compress what is left with DATA_COMPRESSION = PAGE. Most databases that reach the cap are full of logs and blobs.

Can I run scheduled jobs on SQL Server Express?

Yes, from outside the engine. Use sqlcmd from cron or Task Scheduler, a job runner in your application, or the host's own schedules for backups. Only SQL Server Agent itself is missing.

Does the transaction log count towards the 10 GB?

No, only data files count. The log is limited by disk space, which is why an unattended log in the full recovery model is a real risk, and why disk matters when choosing a plan.

Will more RAM raise the 1.4 GB buffer pool limit?

No. The buffer pool stops at 1,410 MB per instance whatever the server has. Extra memory only helps the rest of the process and the operating system, so spend on disk or on better indexes instead.

How do I get alerts without Database Mail?

Make the job script return a non-zero exit code on failure with sqlcmd -b, and have the wrapper send a message to an email address or a chat webhook when it does. The alert then lives in your scheduler, not in the engine.

Was this helpful?