Back to Blog
Python

Python Redis Hashes, Lists, Sets, and Sorted Sets

python redis hashes lists sets and sorted sets: How to use Redis hashes, lists, sets, and sorted sets from Python with redis-py, including when each type fits and how...

RedisPythonredis-pyData StructuresCaching
Diagram of four Redis data structures — hash, list, set, and sorted set — connected to a Python client.

python redis hashes lists sets and sorted sets requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

When you work with Redis from Python, the four collection types — hashes, lists, sets, and sorted sets — cover most of what you will store beyond plain key-value strings. Each type has a distinct shape and a matching set of commands in the redis-py client, and choosing the right one determines how easy the code is to write and how efficiently Redis executes it.

Connecting to Redis from Python

The redis-py client is the standard way to talk to Redis from Python. A minimal connection looks like this:

import redis r = redis.Redis(host="localhost", port=6379, db=0)

redis.Redis returns a client object whose methods map directly to Redis commands. The client handles connection pooling, encoding of Python values into the Redis wire format, and decoding of responses. By default, strings are returned as bytes; passing decode_responses=True makes the client return str instead, which is usually more convenient for application code:

r = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)

With decode_responses=True, every value you read comes back as a Python string, and every string you write is encoded automatically. This matters because Redis stores everything as bytes; the client is responsible for the conversion.

Redis Hashes in Python

A Redis hash maps fields to values inside a single key. It is the closest Redis type to a Python dictionary, and it is the right choice when you need to read or update individual fields without fetching the whole object.

Writing a hash:

r.hset("user:1001", mapping={"name": "Ada", "role": "admin", "active": "1"})

Reading fields:

name = r.hget("user:1001", "name") all_fields = r.hgetall("user:1001")

hgetall returns a dict of all field-value pairs. With decode_responses=True, the keys and values are strings. Without it, they are bytes, so you would need to decode them before using them as dict keys or comparing them to strings.

Hashes are efficient for partial updates. If you only need to change one field of a user record, hset updates that field in place without transferring the rest of the object between client and server. That makes hashes a common choice for session data, user profiles, and any record with a known set of fields.

One practical detail: field values in a hash are always strings. If you store an integer, Redis keeps it as a string, and you must convert it back to int when you read it. redis-py does not automatically convert numeric strings.

Redis Lists in Python

A Redis list is an ordered sequence of strings. It supports push and pop operations on both ends, which makes it useful for queues, stacks, and recent-item lists.

Pushing and reading:

r.rpush("task_queue", "job-1", "job-2", "job-3") first = r.lpop("task_queue")

rpush appends to the right end; lpop removes and returns the left end. Together they form a FIFO queue. If you push on the left and pop on the left, you get a stack (LIFO). Redis also provides brpop and blpop, the blocking variants, which wait for an item to arrive instead of returning immediately when the list is empty:

item = r.blpop("task_queue", timeout=5)

blpop returns a tuple (key, value) when an item is available, or None after the timeout. This is the pattern behind simple worker queues: producers rpush, consumers blpop with a timeout, and the blocking call removes the need for polling.

To read a slice without removing anything, use lrange:

recent = r.lrange("recent_items", 0, 9)

Lists have a maximum length of 2^32 - 1 elements. For high-throughput queues, you can cap growth with ltrim after each push, or use a Redis stream if you need consumer groups and message acknowledgment, which lists do not provide.

Redis Sets in Python

A Redis set is an unordered collection of unique strings. It supports membership tests and set algebra directly on the server, which is useful for deduplication, permissions, and relationship tracking.

Adding members and checking membership:

r.sadd("online_users", "user:1", "user:2", "user:3") is_online = r.sismember("online_users", "user:1")

sismember returns True or False, and the check happens entirely inside Redis. For a large set, this avoids transferring the whole collection to the client just to test one value.

Set operations run server-side:

common = r.sinter("group:a", "group:b") all_members = r.sunion("group:a", "group:b") only_in_a = r.sdiff("group:a", "group:b")

sinter, sunion, and sdiff return new sets computed from the input sets. These operations are useful for things like computing shared permissions between two roles, or finding members present in one group but not another. For very large sets, you can store the result back into Redis with sinterstore, sunionstore, or sdiffstore instead of pulling it into Python.

Removing members uses srem, and scard returns the cardinality:

r.srem("online_users", "user:2") count = r.scard("online_users")

Sets do not preserve insertion order. If ordering matters, you need a list or a sorted set.

Redis Sorted Sets in Python

A sorted set is a set of unique members, each associated with a numeric score. Redis keeps members ordered by score, which makes sorted sets the natural choice for leaderboards, rankings, rate limits, and any data that needs both uniqueness and ordering.

Adding members with scores:

r.zadd("leaderboard", {"player:1": 1200, "player:2": 980, "player:3": 1450})

The mapping form maps member names to scores. To read the top players:

top = r.zrange("leaderboard", 0, 2, desc=True, withscores=True)

zrange returns members in ascending score order by default. With desc=True, it returns the highest scores first. withscores=True makes each result a tuple of (member, score).

Incrementing a score atomically:

r.zincrby("leaderboard", 10, "player:1")

zincrby adds the given amount to the member's score. This is the standard way to update a leaderboard after an event, and it is atomic — concurrent updates cannot lose increments.

Querying by score range:

qualified = r.zrangebyscore("leaderboard", 1000, 2000)

This returns all members whose score falls between 1000 and 2000, inclusive. For paginated leaderboards, zrevrange with start and end indexes gives you a slice of the ranking.

Sorted sets use a skiplist internally, so range queries by score are O(log N) plus the number of returned members. That is efficient enough for leaderboards with millions of entries, as long as you page the results instead of fetching everything.

Choosing the Right Data Structure

The four types overlap in some areas, but each has a clear niche.

TypeOrderingUniquenessTypical use
HashNoneFieldsRecords with known fields
ListInsertionNoQueues, stacks, recent items
SetNoneYesMembership, deduplication, relations
Sorted setBy scoreYesLeaderboards, rankings, rate limits

Use a hash when you have an object with a fixed set of fields and you want to update or read individual fields without transferring the whole record.

Use a list when you need a queue or stack with push/pop on either end, and when duplicate values are acceptable.

Use a set when you need uniqueness and fast membership tests, and when ordering is irrelevant.

Use a sorted set when you need both uniqueness and ordering by a numeric score, or when you need to query members by score range.

A common mistake is storing a JSON-serialized object in a plain string key when a hash would give you field-level access, or using a list where a set would eliminate duplicates automatically. The Redis command you need usually points at the right type: if you find yourself calling lrange and then checking for duplicates in Python, a set or sorted set is probably a better fit.

Performance and Operational Considerations

The main operational concerns with these types are memory usage, atomicity, and the cost of round trips.

Memory: Redis stores every value as a string. A hash with many small fields uses less memory than the same data stored as many separate string keys, because Redis shares the key name across fields. Lists and sets have per-element overhead, and sorted sets carry an additional score and the skiplist structure, so they use more memory per member than a plain set. If memory is tight and you do not need ordering, prefer a set over a sorted set.

Atomicity: Commands like zincrby, sadd, and rpush are atomic on their own. If you need to perform several operations as one unit — for example, adding a member to a set and updating a sorted set in the same step — use a Lua script or a Redis transaction with pipeline(transaction=True):

pipe = r.pipeline(transaction=True) pipe.sadd("processed", "job:1") pipe.zadd("scores", {"job:1": 100}) pipe.execute()

A transaction pipeline buffers the commands and executes them atomically, so other clients cannot observe a partial state.

Round trips: Each Python method call is a separate network round trip. When you need to write many values, batch them in a pipeline:

pipe = r.pipeline() for i in range(1000): pipe.rpush("bulk_queue", f"item-{i}") pipe.execute()

This sends one request instead of a thousand, which matters more than the choice of data type when latency dominates.

Expiry: Redis supports per-key expiry, but it applies to the whole key, not to individual members. You cannot expire a single field in a hash or a single member in a set. If you need per-element lifetime, store a timestamp in a sorted set score and clean up expired members with a background job, or use a separate key per element with its own TTL.

Common Pitfalls When Using redis-py

A few behaviors in redis-py routinely cause bugs.

Bytes versus strings: Without decode_responses=True, hgetall returns bytes keys and values. Comparing those to string literals fails silently, and using bytes as dict keys produces unexpected behavior. Decide on one mode for the whole application and set it at client creation.

hset signature: In older redis-py versions, hset required separate field and value arguments, while newer versions accept a mapping keyword. If you pass a dict as the second positional argument, the behavior depends on the installed version. Use the mapping= keyword explicitly so the call is unambiguous.

zadd argument order: zadd takes the mapping as the second argument in modern redis-py. Passing (score, member) tuples, as some older examples show, will fail or behave differently. Always pass {"member": score}.

Blocking calls: blpop and brpop block the calling thread until an item arrives or the timeout expires. In a web application with a small thread pool, a long timeout can exhaust available workers. Use a short timeout and retry, or run blocking consumers in a dedicated worker process.

Empty results: lpop on an empty list returns None, and zrange on an empty sorted set returns an empty list. Code that assumes a value is always present needs to handle None explicitly, especially in queue consumers where an empty list is a normal condition, not an error.

That covers the four collection types, their Python API, and the operational details that affect how you use them in production. The right choice usually follows from one question: what shape does the data need — fields, sequence, uniqueness, or score-ordered uniqueness — and Redis has a type that matches each answer.

python redis hashes lists sets and sorted sets: Practical Us | RYUSLOG DEV