Atomic Counters: INCR and DECR
Redis strings can hold numbers, and Redis gives you dedicated commands — INCR, DECR, INCRBY, DECRBY, and INCRBYFLOAT — that read a key’s value as a number, change it, and write it back in a single atomic step on the server. This matters because counters (page views, rate limits, stock levels, scores, wallet balances) are one of the most common Redis workloads, and doing “read, calculate, write” yourself from application code is not safe once more than one client is involved. These commands let many clients update the same counter at the same time without ever losing an update.
Overview: How INCR and DECR Work
A Redis string is a binary-safe sequence of bytes that can store text, serialized data, or — the case that matters here — a number written out in decimal. INCR key tells Redis: parse the current value of key as a base-10 signed 64-bit integer, add 1 to it, store the result back as a string, and return the new value. If key does not exist yet, Redis treats its value as 0 before performing the operation, so calling INCR on a brand-new key creates it with the value 1. DECR key is the mirror image — it is defined as exactly the same operation as INCRBY key -1.
INCRBY key increment and DECRBY key decrement work the same way but move the value by an arbitrary integer amount instead of always by one. INCRBYFLOAT key increment does the equivalent for floating-point numbers: it parses the value as a double-precision float, adds increment (which may be negative), and stores the result formatted with trailing zeros trimmed. There is no DECRBYFLOAT command — to subtract, pass a negative increment to INCRBYFLOAT.
The reason these commands are safe under concurrency is Redis’s single-threaded execution model: the server processes one command to completion before starting the next, no matter how many clients are connected. Inside INCR, the read, the arithmetic, and the write all happen within that one command’s execution slot, so no other command from any client can be interleaved in the middle. Compare that to doing it yourself: GET counter, add 1 in your application, then SET counter with the result. Between the GET and the SET, another client can run its own GET, see the same old value, and also compute “old value + 1.” Both clients then write the same number back, and one increment is silently lost. INCR never has this gap because the whole read-modify-write happens on the server in one step.
Two behaviors are easy to get wrong if you haven’t seen them stated explicitly. First, if the key holds a value that Redis cannot parse as an integer (extra characters, a decimal point, leading or trailing whitespace), INCR/DECR/INCRBY/DECRBY return an error and leave the stored value completely unchanged — the key is untouched. Second, Redis integers are bounded to the signed 64-bit range (roughly ±9.2 quintillion); if the operation would push the value outside that range, Redis again returns an error and leaves the value unchanged rather than silently wrapping around.
Also worth knowing: a plain SET on an existing key clears any TTL that key had (unless you add the KEEPTTL option), but INCR and its siblings never touch a key’s expiration at all — they only rewrite the value in place. A counter with a 60-second TTL keeps counting down normally across many INCR calls; the TTL is neither reset nor cleared by them. Internally, when the stored value is a plain integer, Redis can use a compact “int” encoding for the string object instead of a raw byte buffer, which is one reason integer counters are cheap to store and fast to update.
Syntax
The general forms:
INCR key
DECR key
INCRBY key increment
DECRBY key decrement
INCRBYFLOAT key increment
| Command | Arguments | Returns | Time Complexity |
|---|---|---|---|
INCR key |
key — the string key to increment (created at 0 first if absent) |
Integer reply: value after the increment | O(1) |
DECR key |
key — the string key to decrement |
Integer reply: value after the decrement | O(1) |
INCRBY key increment |
increment — integer amount to add (may be negative) |
Integer reply: value after the operation | O(1) |
DECRBY key decrement |
decrement — integer amount to subtract |
Integer reply: value after the operation | O(1) |
INCRBYFLOAT key increment |
increment — floating-point amount to add (may be negative) |
Bulk string reply: value after the operation, as a string | O(1) |
Examples
Example 1: A Simple Page View Counter
SET counter:pageviews 10
INCR counter:pageviews
INCR counter:pageviews
DECR counter:pageviews
GET counter:pageviews
Output:
OK
(integer) 11
(integer) 12
(integer) 11
"11"
The counter starts at 10 as plain text stored with SET. Each INCR parses that text as an integer, adds one, and returns the new value directly as an integer reply — there’s no need for a separate GET to see the result. The final GET shows the value is back to a bulk string reply, "11", because GET always returns strings regardless of what the string looks like.
Example 2: INCR Creates a Missing Key
INCR counter:signups:2026-08-10
INCR counter:signups:2026-08-10
INCR counter:signups:2026-08-10
GET counter:signups:2026-08-10
Output:
(integer) 1
(integer) 2
(integer) 3
"3"
counter:signups:2026-08-10 did not exist before this block ran. The first INCR treats the missing key as 0, adds one, and creates it with the value 1 — no separate initialization step is needed. This is the pattern behind most Redis counters: just start calling INCR.
Example 3: Inventory Stock with INCRBY and DECRBY
SET inventory:sku1001:stock 50
DECRBY inventory:sku1001:stock 3
DECRBY inventory:sku1001:stock 2
INCRBY inventory:sku1001:stock 10
GET inventory:sku1001:stock
Output:
OK
(integer) 47
(integer) 45
(integer) 55
"55"
Two orders reduce stock by 3 and then 2 units with DECRBY, and a restock adds 10 back with INCRBY. Because each of these is a single atomic command, two warehouse terminals — or two web requests — changing stock for the same SKU at the same time will never race each other into an incorrect count the way a manual GET/subtract/SET sequence could.
Example 4: A Floating-Point Wallet Balance
SET wallet:user42:balance 10.5
INCRBYFLOAT wallet:user42:balance 0.25
INCRBYFLOAT wallet:user42:balance -2
GET wallet:user42:balance
Output:
OK
"10.75"
"8.75"
"8.75"
INCRBYFLOAT accepts a negative increment to subtract, since there is no separate DECRBYFLOAT command. Notice both INCRBYFLOAT calls return a bulk string, not an integer reply — floating-point results are always returned as strings, formatted to avoid unnecessary trailing zeros.
How It Works Step by Step
When the server receives INCR key (the other variants follow the same path with a different delta):
- The single command-processing thread looks up
keyin the keyspace. As part of any lookup, Redis first checks lazy expiration — if the key has a TTL that already passed, it’s deleted on the spot and treated as absent. - If the key is absent, Redis treats its current value as
0. If it’s present, Redis reads the stored bytes and attempts to parse them as a base-10 signed 64-bit integer. - If parsing fails (non-numeric content, extra characters, a float), Redis aborts immediately, leaves the key completely unchanged, and returns
(error) ERR value is not an integer or out of rangeto the client. - If parsing succeeds, Redis computes the new value and checks whether it still fits in the signed 64-bit range. If it would overflow, Redis aborts, leaves the value unchanged, and returns an overflow error instead.
- If the new value is valid, Redis writes it back into the same key, often switching the string’s internal encoding to a compact integer representation. The key’s TTL, if any, is left exactly as it was.
- Redis returns the new value as a reply to the client. Because steps 1–6 all happen inside one command’s execution — with no other command allowed to run in between — the whole operation is atomic from every client’s point of view.
Common Mistakes
Mistake 1: Simulating INCR with GET and SET
SET counter:hits 5
GET counter:hits
SET counter:hits 6
Output:
OK
"5"
OK
Nothing here errors, which is exactly why this mistake is dangerous — it looks fine in a single-client test. The problem only appears under concurrency: if two clients both run GET counter:hits before either has called SET, both read 5, both compute 6 in their own code, and both write 6 back — one of the two increments is silently lost. The fix is to let the server do the read-modify-write atomically:
SET counter:hits 5
INCR counter:hits
Output:
OK
(integer) 6
Mistake 2: Calling INCR on a Non-Numeric Value
SET product:1001:name "Wireless Mouse"
INCR product:1001:name
Output:
OK
(error) ERR value is not an integer or out of range
product:1001:name holds text, not a number, so Redis refuses the operation and leaves the key untouched — it does not overwrite it with 1. This is not the same error as WRONGTYPE (which appears when you use, say, a list command against a key of type string); the type here is still “string,” the content just isn’t a valid integer.
Mistake 3: Always Resetting the TTL on a Rate Limiter
A fixed-window rate limiter should set a TTL only when the window’s key is brand new — that is, only on the request where INCR returns 1. Setting the TTL unconditionally on every request instead means the window’s expiration keeps getting pushed forward as long as traffic continues, so the window never actually closes:
INCR ratelimit:ip:203.0.113.7
EXPIRE ratelimit:ip:203.0.113.7 60
TTL ratelimit:ip:203.0.113.7
Output:
(integer) 1
(integer) 1
(integer) 60
In application code, check the reply of INCR and only call EXPIRE when it equals 1. Since INCR never sets or clears a TTL by itself, skipping this EXPIRE step entirely is its own mistake — the counter key would then live in memory forever with no expiration at all.
Best Practices
- Always use
INCR/DECR/INCRBY/DECRBYinstead of a client-sideGET-then-SETsequence whenever a value needs to change relative to its current value — the server-side operation is what makes it atomic. - For rate limiters and other fixed-window counters, set the TTL only on the request where
INCRreturns1, so the window has a fixed lifetime instead of sliding forward on every hit. - Prefer integer minor units (cents, not dollars) with
INCRBY/DECRBYfor money instead ofINCRBYFLOAT, since binary floating point can accumulate small rounding errors over many operations. - Remember that
INCRsilently creates a missing key at1— if a counter existing at all is meaningful in your application, check withEXISTSfirst rather than assuming. - Never scan for counter keys in production with
KEYS counter:*; it blocks the single-threaded server for the entire scan. UseSCANwith aMATCHpattern instead. - Set a TTL, or otherwise plan for cleanup, on any counter whose key space grows without bound — for example one key per user per day — since Redis will otherwise hold every one of them in memory forever.
Practice Exercises
- Create a view counter for a blog post with the slug
redis-incr-decr, following this site’s colon-namespaced key convention. Increment it three times, then decrement it once, and confirm the final value withGET. What should the key be named, and what is the final value? - Build the first request of a one-minute, 100-request rate limiter for IP address
198.51.100.23: increment a counter keyed by that IP, and only if the counter’s new value is1, give it a 60-second TTL. Then write the command a later request in the same window would use to check the remaining TTL without resetting it. - A wallet key
wallet:user77:balancecurrently holds100.00. The user is refunded15.50. Which single command updates the balance, and what value wouldGET wallet:user77:balancereturn afterward?
Summary
INCR,DECR,INCRBY,DECRBY, andINCRBYFLOATread a string as a number, change it, and write it back in one atomic, O(1) server-side step.- Atomicity comes from Redis’s single-threaded execution model — no other command can interleave between the read and the write.
- A missing key is treated as
0first, soINCRon a new key creates it with the value1. - A non-numeric existing value, or a result outside the signed 64-bit integer range, returns an error and leaves the stored value unchanged.
- Unlike a plain
SET, these commands never set or clear a key’s TTL — they only rewrite the value. - There is no
DECRBYFLOAT; pass a negative amount toINCRBYFLOATto subtract. - Prefer these atomic commands over a manual
GET-then-SETin application code to avoid lost updates under concurrency.
