Redis vs Database for Sessions: What Actually Differs
Every result on the first page compares speed. The choice is made by four other things: who deletes expired sessions, how you log someone out everywhere, what a restart costs, and how long the copies live.
The question usually shows up on the day an application gets a second instance. A user logs in, the next request lands on the other container, and they are logged out; one refresh later they are back in. Or it shows up as three lines in a production log that nobody on the team wrote.
Search for the comparison and the first page, as of writing, is an argument about speed. A Reddit thread, a fourteen-year-old Stack Overflow question from someone moving their sessions off MySQL because it felt slow, a 2019 benchmark from a German hosting company, several posts explaining that memory is faster than disk. All of it is true, and it is rarely what decides the matter.
What decides Redis vs database for sessions is who deletes a session once it has expired, how you log a user out of every device, what a restart or memory pressure does to people who are logged in, and how long copies of a session outlive the session itself. The two stores give opposite answers to most of those, which is why the choice is less obvious than the benchmarks make it look.
MemoryStore is not designed for a production environment
Those three log lines are the Node.js version of the problem. express-session prints them when NODE_ENV is production and nobody configured a store:
Warning: connect.session() MemoryStore is not
designed for a production environment, as it will leak
memory, and will not scale past a single process.The package's README is blunter still: the default store "is purposely not designed for a production environment. It will leak memory under most conditions, does not scale past a single process, and is meant for debugging and developing."
Two questions hide in that warning, and they are worth separating. The first is whether sessions live inside the application process, and for anything running more than one instance the answer has to be no. The second is where they live instead, which is the actual Redis-or-database decision. Most frameworks have already answered the first for you, and they did not all answer it the same way.
| Framework | Default session storage | With a second instance |
|---|---|---|
Express (express-session) | MemoryStore, inside the Node.js process | Each instance has its own sessions, and the warning above is printed in production |
| Django | Database, django.contrib.sessions.backends.db | Works across instances; expired rows stay until clearsessions runs |
| Laravel | Database, SESSION_DRIVER=database | Works across instances; expired rows are swept on a lottery |
| Rails | Encrypted cookie, ActionDispatch::Session::CookieStore | Works across instances with no server-side state, a 4 kB limit and no server-side revocation |
config/session.php in your own project rather than a tutorial.Read the table the other way round and it says something useful. Two of these four frameworks already keep sessions in your relational database out of the box, so for a lot of teams the practical question is whether to move off the store they already have.
Sticky sessions: the answer that postpones the question
Before choosing a store, many teams choose not to. Sticky sessions, also called session affinity, tell the load balancer to send every request from a given user to the same instance, so in-process sessions keep working with more than one container.
That holds until the instance goes away, which on a modern platform is routine rather than an incident. A deploy replaces every container, scaling down removes some, and a failed health check moves traffic elsewhere. Each of those logs out everyone who was pinned to the instance that left, and a rolling or blue-green release does it to the whole user base on every deploy. Affinity also skews load, because a busy user stays on one instance however loaded it gets.
The generated answer at the top of Google's results for the term ends, as of writing, by offering to help you "decide whether sticky sessions or a shared session store fits your project better". That is the right framing, and it is left open. For login state, affinity defers the store decision until the first deploy makes it for you. It has honest uses elsewhere, such as keeping a reconnecting WebSocket on a warm instance, but as a home for sessions it only moves the problem.
Redis vs database for sessions: what actually differs
Put the same session in both stores and compare what happens to it over its life, rather than how long one read takes.
| Database table | Redis | |
|---|---|---|
| Reading the session | An indexed lookup by primary key, over a pooled connection | A key lookup in memory |
| Writing it back | A row update: a new row version, a WAL record, work for autovacuum | An in-memory write; persistence, if enabled, happens in the background |
| Expiry | Nothing deletes expired rows until a sweeper runs | A TTL on the key, and Redis deletes it |
| Sign out everywhere | One DELETE, if the user ID is a column | Needs a per-user index you maintain |
| Restart | Nobody is logged out | Sessions created since the last persisted point are lost, or all of them without persistence |
| Memory pressure | Not a session concern | Failed logins or random logouts, depending on the eviction policy |
| Backups | Copied into every backup for the retention window | Rarely backed up; snapshot files hold only live sessions |
Django does not provide automatic purging of expired sessions
Django's documentation says it in those words, and the sentence holds for every relational session store, not only Django's. A row in a sessions table with an expiry date in the past is still a row: it is ignored when read and stays on disk until something deletes it. Django's answer is a management command, clearsessions, which the docs recommend running "on a regular basis, for example as a daily cron job". Laravel's answer is a lottery, in which a small share of ordinary requests sweeps expired sessions while serving the page.
# Laravel: .env
SESSION_DRIVER=database # the default in current releases
SESSION_LIFETIME=120 # minutes
# Laravel: config/session.php
'lottery' => [2, 100], // "By default, the odds are 2 out of 100."
# Django: nothing deletes expired rows until this runs, e.g. from crontab
0 3 * * * python manage.py clearsessionsThe connect-pg-simple store for Express runs a timer of its own, pruning expired sessions every 900 seconds by default. The mechanisms differ and the failure is the same everywhere: a sweeper that was never scheduled, or one switched off for being slow on a table that had already grown to millions of rows. A sessions table without a working sweeper is an append-only log of every visit that ever created a session.
Redis has no equivalent job, because expiry is a property of the key. Set a TTL equal to the session lifetime and Redis removes the key when it lapses; connect-redis takes that TTL from the cookie's expiry and falls back to one day. Laravel's session handler interface even notes that for self-expiring stores such as Redis the garbage-collection method "may be left empty". If sessions stay in the database, the sweep is a scheduled job like any other and belongs wherever the rest of your scheduled work runs.
Logging someone out everywhere
"Sign out of all devices", a password reset and a suspended account all need every session belonging to one user to stop working immediately. OWASP's session management guidance is explicit that the application has to invalidate sessions on the server side. Deleting a cookie in one browser does nothing to the other five.
In a relational table that is a single statement, provided the user ID is a column. Laravel's default sessions table has one, and it is indexed. Django's django_session table does not: it holds a session key, encoded session data and an expiry date, so finding one user's sessions means decoding rows or keeping a mapping of your own.
Redis finds a session by its ID and by nothing else, and the common session libraries store exactly that. Logging a user out everywhere means maintaining a second structure yourself, typically a set of session IDs per user.
-- Database: one statement, if user_id is a column (Laravel's default table)
DELETE FROM sessions WHERE user_id = 42;
# Redis: record the session in a per-user set when it is created...
SADD user:42:sessions sess:9f21c4a7
EXPIRE user:42:sessions 1209600
# ...and walk the set on "sign out everywhere"
SMEMBERS user:42:sessions
DEL sess:9f21c4a7 sess:c03be118 user:42:sessionsThe set outlives members that expired on their own, so deleting a key that is already gone is normal and harmless. What is not harmless is skipping the index and discovering, on the day someone reports a stolen laptop, that the only way to find their sessions is to scan every key and deserialise every value.
What a restart and an eviction do to each
A database restart logs nobody out. A Redis restart loses every session created after the last point Redis persisted, and all of them if persistence is off. With snapshots alone that can be several minutes of logins; with an append-only file flushed every second, it is typically about a second. Whether a restart costs your users anything is a configuration question, and it is better answered before the first restart answers it for you.
Memory pressure is the quieter failure, and the eviction policy decides its shape. Under noeviction a full instance refuses writes, so new logins fail with OOM command not allowed. Under allkeys-lru sessions are deleted alongside cache entries and users are logged out at random. The volatile-* policies are worse than they sound for sessions: they consider only keys with a TTL, sessions always have one, and a cache written without expiry times is never touched, so the sessions are what goes. Why a keyspace that is half cache and half store has no correct policy is the long version. The short one is that sessions want an instance, or at least a memory budget, where eviction is not part of the plan.
There is a middle option on the Postgres side, worth a mention because it looks like a free lunch. An UNLOGGED table skips the write-ahead log, which makes writes to it considerably faster.
CREATE UNLOGGED TABLE sessions (
id text PRIMARY KEY,
user_id bigint,
payload jsonb NOT NULL,
expires_at timestamptz NOT NULL
);
CREATE INDEX ON sessions (user_id);
CREATE INDEX ON sessions (expires_at);The PostgreSQL documentation states the price plainly: an unlogged table "is automatically truncated after a crash or unclean shutdown", and its contents "are also not replicated to standby servers". On a managed database with a standby, a failover promotes a replica whose sessions table is empty. You have rebuilt the behaviour of a Redis restart inside Postgres, minus the TTL.
When the database becomes the wrong place
Reading a session by primary key is cheap for any relational database. Most of the cost is the network round trip, and Redis pays a round trip too. If reads were the only difference, a database-backed session store would last far longer than most teams assume.
Writes are where it changes, because many frameworks write the session back on every request rather than only when something in it changed. In current Laravel releases the session middleware saves the session at the end of each stateful request, and express-session stores that implement touch update the expiry on activity. Django is the exception: it writes only when the session was modified, unless SESSION_SAVE_EVERY_REQUEST is switched on.
In PostgreSQL each of those updates is a new row version, a WAL record and a dead tuple for autovacuum, on a table whose working set is small and whose churn is total. Each one also holds a connection while it runs, which is where session traffic meets the connection ceiling a pooler exists to protect. The signal that sessions should leave the database shows up on the write side: session updates among your most frequent statements, a sessions table that bloats faster than vacuum reclaims it, or a connection pool whose busiest clients are page views that changed nothing.
About the benchmark on the first page
The most visible number for this comparison in the German results comes from maxcluster, a hosting company, published on 26 September 2019: "Redis achieves a 33% higher throughput in read operations than the NVMe SSDs and almost three times higher than MySQL." It is a real measurement of read throughput on their hardware at the time. It says nothing about expiry, revocation or restarts, and at the traffic most applications see, read throughput is not the limit that arrives first.
Redis vs database for sessions vs cache: running both
Storing sessions in the database and Redis at once is a real option, and Django ships it. Django's cached_db session backend, in the documentation's words, "uses a write-through cache – session writes are applied to both the database and cache, in that order". Reads come from the cache and fall back to the database when the key is missing.
# settings.py
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "rediss://default:password@cache.example.eu:6379",
}
}This keeps the database as the record and Redis as the fast path. A flushed or restarted Redis costs a round of cache misses rather than a round of logouts, and signing someone out everywhere still happens against rows. The price is that every session write touches both stores, and the sweeper is still yours to schedule. It is the write-through pattern applied to a single table, with the same trade of consistency paid for on every write. Most other frameworks leave the hybrid to you, and the order is the part to copy: database first, cache second.
Django's docs attach a warning that applies to every framework. Cache-backed sessions should only use Memcached or Redis, because the local-memory cache backend "is NOT multi-process safe". That is the MemoryStore problem again under a different name.
The option with no server-side store at all
Rails takes a third route by default. Its CookieStore puts the whole session, encrypted and signed, into the cookie, so there is no store to choose and nothing to share between instances. That scales without effort and costs two things. One is size, and the Rails security guide puts it directly: "Cookies have a size limit of 4 kB. Use cookies only for data which is relevant for the session." The other is control: "Session cookies do not invalidate themselves and can be maliciously reused."
Signed tokens of every kind, JWTs included, share that second property. A token the server cannot recall is valid until it expires, so logging a user out everywhere needs a denylist or a per-user version number checked on each request. Both are server-side state read on every request, which brings you straight back to the question this article is about, only with a smaller record to store.
Your session table is in your backups
The case that sessions are personal data has already been made for the cache in front of your database: session tokens and user identifiers fall under the GDPR however temporary they feel. Choosing a store adds a question that argument does not reach, which is how long the copies live.
A sessions table in PostgreSQL is copied into every backup and every archived WAL segment for as long as the retention window lasts. Laravel's default table stores ip_address and user_agent next to user_id, so those copies are a record of which device, from which address, used which account, kept for days or weeks after each session ended. Deleting the row at logout removes it from the live database and from nothing else.
A Redis session with a TTL leaves no such trail. Snapshot and append-only files contain the sessions that were live when they were written, and Redis is rarely the component anyone keeps thirty days of backups for. The GDPR's storage limitation principle in Article 5(1)(e) asks that personal data be kept in identifiable form for no longer than necessary, and that is an easier argument to make for a key that disappears with the session than for a row restored with every backup.
None of this rules the database out. It means the retention of your backups becomes the retention of your session data, which is worth writing down in your record of processing, and where those copies physically sit is part of the same residency answer as the database itself.
Which one to pick
- Stay in the database if you already have one, run a handful of instances and need to query sessions: a list of active devices, sign-out everywhere, an audit trail. Schedule the sweeper on day one and count the sessions table in your backup retention.
- Move sessions to Redis when session writes show up in database load or in the connection pool, or when you already run Redis and sessions are its next obvious tenant. Set the TTL to the session lifetime, keep a per-user set if you need revocation, and put the keys where eviction will not reach them.
- Run both when you want the database as the record and Redis taking the reads. Django's
cached_dbdoes this out of the box, and any framework can do it by writing to the database first and the cache second. - Use signed cookies when the session is a couple of identifiers, fits comfortably under 4 kB, and revocation through a denylist is acceptable.
- Do not treat sticky sessions as a store. They keep in-process sessions alive until the next deploy, which on most platforms is this week.
If you were looking for a music shop in Frankfurt
In Germany the most searched phrase containing the words "session store" is session the music store frankfurt, at around 1,900 searches a month against about 90 for the software term. Nothing on this page will help with guitars, although it is the same city the servers mentioned below run in.
How Runsite handles it
Both stores in this article run as toggles next to your application, on the same invoice. Managed Redis in Frankfurt is Redis 7+ with TLS on by default and, as of writing, persists with RDB snapshots and AOF on every plan, with automatic failover, so a restart or a failover does not log your users out. It is not included in backups, which for sessions is the behaviour the previous section argues for. The eviction question stays with you: size the instance for its sessions rather than sharing a tight memory budget with a cache.
Managed PostgreSQL with daily backups and point-in-time recovery runs PostgreSQL 16, keeps backups for seven days on the entry plans and up to thirty on larger ones, and has PgBouncer built in for the connection side of session traffic. If sessions stay in the database, that retention window is the one to record against them, and the sweeper can run as a scheduled job on the same platform.
Instances, RDB and AOF files and database backups all stay in Germany as of writing, and a signed GDPR data processing agreement comes with every account. What we do not provide is a session layer: which store, which TTL and how revocation works are decisions for your application, and the setup steps are in the Runsite docs.
The short version
- The question is really two: whether sessions leave the application process, which they must once you run two instances, and where they go instead.
express-sessionwarns thatMemoryStore is not designed for a production environmentfor a reason. Django and current Laravel already keep sessions in the database by default, and Rails keeps them in an encrypted cookie.- Sticky sessions postpone the decision until a deploy, a scale-down or a crash logs out everyone pinned to the instance that left.
Django does not provide automatic purging of expired sessions, and no relational store does. Django needsclearsessionson a schedule, Laravel sweeps on a 2-in-100 lottery, and Redis deletes a key when its TTL lapses.- Signing a user out everywhere is one
DELETEon a table with auser_idcolumn. In Redis it needs a per-user set that you maintain. - A Redis restart loses sessions written since the last persisted point. Under memory pressure
noevictionbreaks logins,allkeys-lrulogs people out at random, andvolatile-*evicts sessions first when cache keys carry no TTL. - An
UNLOGGEDPostgres table is faster to write, and a crash or a failover to a standby empties it. - The database becomes the wrong place when session writes, not reads, show up in load, bloat or the connection pool. Laravel writes the session on every request; Django only when it changed.
- Django's
cached_dbruns both stores as a write-through cache, with the database as the record. - Signed cookies and JWTs remove the store and the ability to revoke. Getting revocation back means server-side state again.
- A sessions table sits in every backup for the whole retention window, often with IP addresses and user agents attached. A Redis session with a TTL leaves no such copies.