# What is the Redis String Data Type?

> Deep dive into the Redis string data type: SDS internals, int/embstr/raw encodings, all 25 commands, atomic counters, and Upstash Redis SDK examples.

TL;DR: A Redis string is a binary-safe byte sequence (0 to 512 MB) stored against a single key. It is the oldest and simplest of Redis's data types, but it backs three different internal encodings (`int`, `embstr`, `raw`) and powers patterns far beyond key-value caching: atomic counters, distributed locks, bitmaps, rate limiters, and HTTP fragment caches. Every other Redis type is built on the same Simple Dynamic Strings (SDS) library underneath.

## Key takeaways

- A single Redis string can hold up to **512 MB** of arbitrary bytes: text, JPEGs, protobufs, or a serialized PyTorch tensor.
- Strings have three encodings: `int` (for values that fit in a `long`), `embstr` (short strings, single allocation), and `raw` (longer strings, two allocations).
- The `embstr` cutoff was a constant `44` bytes for years; [Redis 8.2 changed it to a 64-byte cache-line heuristic](https://github.com/redis/redis/issues/14438) that depends on both key and value length.
- `INCR`, `INCRBY`, and `INCRBYFLOAT` operate on a 64-bit signed integer parsed from the string and are atomic: no race even with thousands of concurrent clients.
- Over the [@upstash/redis](https://upstash.com/docs/redis/sdks/ts/commands/string) REST SDK, `SET`, `GET`, `INCR`, and `MGET` each cost a single HTTP round trip, which makes pipelining and `MGET`/`MSET` materially more important than on TCP clients.

## What is a Redis string under the hood?

A Redis string is not a C string. Internally it is an [SDS (Simple Dynamic String)](https://redis.io/docs/latest/operate/oss_and_stack/reference/internals/internals-sds/), a header plus a flat byte buffer. The header carries the length, the allocated capacity, and a 1-byte type tag. Three properties fall out of that design:

1. **`STRLEN`&#32;is O(1).** The length is a field, not a `strlen` walk.
2. **Strings are binary-safe.** `\0` is just another byte, so you can store images or Protobuf payloads.
3. **`APPEND`&#32;is amortized O(1).** SDS doubles free capacity on reallocation, exactly like a C++ `std::vector`.

On top of SDS, Redis picks one of three encodings per key. You can inspect it with `OBJECT ENCODING`:

```tsx id="code-u72feutv"
> SET counter 42
OK
> OBJECT ENCODING counter
"int"
> SET greeting "hi"
OK
> OBJECT ENCODING greeting
"embstr"
> SET blob "$(head -c 200 /dev/urandom | base64)"
OK
> OBJECT ENCODING blob
"raw"
```

The `int` encoding skips SDS entirely. The value lives directly in the object's pointer slot as a `long`, which is why `INCR` can be O(1) and lock-free: there is no parsing on the hot path.

## When does Redis use embstr vs raw encoding?

For most of Redis's history the rule was a hard constant: values up to `OBJ_ENCODING_EMBSTR_SIZE_LIMIT = 44` bytes used `embstr`, anything longer used `raw`. The difference matters because:

- **`embstr`** allocates the `robj` header and the SDS buffer in a single contiguous block. One `malloc`, one cache line, faster reads.
- **`raw`** does two allocations: the `robj` points at a separately allocated SDS. Slower, but supports in-place mutation.

In [Redis 8.2 the rule changed](https://github.com/redis/redis/issues/14438). Keys and values were unified into a single `kvobj` struct, and the new heuristic is roughly "embed only if the total kvobj + key SDS + value SDS fits in one 64-byte cache line." That means a 40-byte value with a 4-byte key is `embstr`, but the same 40-byte value with a 40-byte key flips to `raw`. The 44-byte folklore is now incomplete; the threshold depends on key length too. Redis maintainers have already [floated relaxing it again](https://github.com/redis/redis/issues/14438#issuecomment-3436854191) to allow small cache-line spillover.

Practical implication: if you care about memory at scale, keep frequently-touched keys short. A 1 KB key is not free; it can push the value out of `embstr` and double the allocator overhead.

## What commands operate on Redis strings?

There are [25 string commands](https://redis.io/docs/latest/commands/?group=string) in the modern Redis 8 reference. The ones worth memorizing:

| Command | Complexity | What it does |
| --- | --- | --- |
| `SET` / `GET` | O(1) | Assign or read a value, replacing any existing type |
| `SETNX` / `SET ... NX` | O(1) | Write only if the key does not exist (the lock primitive) |
| `GETEX` | O(1) | Read and refresh TTL atomically |
| `GETDEL` | O(1) | Read and delete atomically (counter rotation) |
| `INCR` / `INCRBY` / `DECR` | O(1) | Atomic 64-bit signed integer math |
| `INCRBYFLOAT` | O(1) | Atomic IEEE-754 double math |
| `APPEND` | O(1) amortized | Push bytes onto the end |
| `STRLEN` | O(1) | Byte length |
| `MGET` / `MSET` | O(N) keys | Batched read/write in one round trip |
| `GETRANGE` / `SETRANGE` | O(N) | Substring read or in-place overwrite at an offset |

A trap worth calling out: `GETRANGE`, `SETRANGE`, and `SUBSTR` are O(N) in the returned (or written) length. `STRLEN` on a 100 MB blob is instant; `GETRANGE` of that blob is not. Treat anything random-access on large strings as a slow command.

## How do you use Redis strings with the @upstash/redis SDK?

The [Upstash Redis SDK](https://upstash.com/docs/redis/sdks/ts/commands/string) speaks REST/HTTP instead of RESP, which means every command is a round trip. The API itself mirrors the standard Redis command set:

```tsx id="code-rq6l4gf4"
import { Redis } from "@upstash/redis";

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

// basic assignment
await redis.set("user:42:name", "Ada");
const name = await redis.get<string>("user:42:name"); // "Ada"

// SET with TTL + NX, the canonical "acquire lock" call 👇
const got = await redis.set("lock:order:99", "worker-7", { nx: true, px: 30_000 });
const acquired = got === "OK"; // false if already held

// atomic counter, increments and returns the new value
const views = await redis.incr("views:/pricing");

// atomically take the current value and reset to nil 👇
const previous = (await redis.getdel<number>("views:/pricing")) ?? 0;
```

Two SDK-specific notes:

1. `redis.mset({...})` accepts a plain object and JSON-encodes non-string values automatically. So `redis.mset({ "user:42": { name: "Ada" } })` stores a JSON string; `redis.get<User>("user:42")` parses it back. The wire format is still a Redis string; the SDK does the serialization.
2. `redis.mget(...keys)` and `redis.mset({...})` [bill as one command](https://upstash.com/docs/redis/sdks/ts/commands/string#mget), not N, which makes batching free on top of being fast.

The full set of typed snippets above (lock acquisition, counter rotation, MGET cart loading, `INCRBYFLOAT` balances, `GETEX` cache refresh, `SETRANGE` byte patching) type-checks against `@upstash/redis` v1 with `strict: true`.

## Why is INCR atomic and what does it do?

`INCR` is the single most useful string command after `SET`/`GET`, and the atomicity guarantee is exact: even with thousands of concurrent clients hitting the same key, the final value is the sum of all increments, never lost, never doubled. Redis is single-threaded for command execution, so `INCR` is a read-parse-add-write sequence that runs to completion before the next command starts.

What `INCR` does mechanically:

1. Look up the key. If missing, treat the value as `0`.
2. Parse the string as a signed 64-bit integer. If it does not parse, return an error (`ERR value is not an integer or out of range`).
3. Add 1 (or N, for `INCRBY`). If the result overflows `int64`, return an error.
4. Re-encode as a string and store. The encoding stays `int` so the next `INCR` skips parsing.

The bounds are `-9_223_372_036_854_775_808` to `9_223_372_036_854_775_807`, roughly 9.2 quintillion. You will not hit them with page views, but you will hit them if you `INCRBY 1e18` twice. `INCRBYFLOAT` operates on doubles and has the usual IEEE-754 precision caveats; do not use it for currency that needs cent-exact rounding. Use `INCRBY` against cents instead.

The same atomicity story is why `SET key value NX PX 30000` is the textbook distributed-lock primitive: NX makes the write conditional, PX bounds the hold time, and both are evaluated server-side without a CAS loop.

## When should you use a Redis string vs a hash or JSON?

Strings are the right answer more often than people assume, but not always. The honest comparison:

| Use case | Best fit | Why |
| --- | --- | --- |
| Single scalar value (counter, flag, session token) | **String** | One allocation, `INCR`/`GETEX` work directly |
| Serialized object you always read whole | **String** with JSON or MessagePack | One `GET` round trip, no field-level overhead |
| Object where you mutate individual fields | **Hash** | `HINCRBY`/`HSET` avoid round-tripping the full value |
| Deeply nested JSON with path queries | **RedisJSON** | `JSON.SET $.user.address.city` server-side |
| Bitmap or bitset (presence, feature flags) | **String** with `SETBIT`/`BITCOUNT` | Bitmaps are strings under the hood |
| Many small related values | **Hash** | Hashes pack into `listpack` encoding while they stay under 512 entries and each value under 64 bytes (the Redis 7+ defaults), saving memory |

The bitmap point is the one most people miss: there is no separate "bitmap" type in Redis. `SETBIT mykey 1000000 1` allocates a 125 KB string and flips bit 1,000,000. The bit-level commands (`SETBIT`, `GETBIT`, `BITCOUNT`, `BITOP`, `BITPOS`, `BITFIELD`) all operate on regular string keys.

If you are storing a structured object and you JSON-encode it into a string, the cost is the full round trip on every field change. If your access pattern is "read one field, write one field", a Redis hash beats it. If your access pattern is "fetch the whole user record on every request", a string is faster and uses less memory.

## What are the performance limits of Redis strings?

The hard limit is **512 MB per value**. Practical limits are tighter:

- **Network.** A `GET` on a 100 MB string serializes 100 MB onto the wire. On Upstash's REST API that is a single HTTP response, and you will hit body-size limits before Redis does.
- **Single-threaded blocking.** Any O(N) command on a giant string blocks the whole Redis instance for the duration. A `GETRANGE` returning 50 MB can stall every other client for tens of milliseconds.
- **Allocation overhead.** Every `raw` string is two jemalloc allocations: the `robj` header and the separately allocated SDS buffer. Packing them into a hash does not avoid this if your values exceed [`hash-max-listpack-value`](https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/) (default **64 bytes**); above that threshold the hash converts to `hashtable` encoding with its own per-field allocations. The "hashes save memory" trick is real, but it pays off for many small fields under 64 bytes, not for 1 KB–10 KB blobs.
- **Counter contention.** `INCR` is atomic, but every increment is still a write that lands on a single shard. If you need millions of increments per second on one logical counter, shard it across N keys and sum on read.

For the common case (config flags, session blobs under 4 KB, counters, idempotency tokens, rate-limit buckets), none of these matter. Redis strings comfortably do hundreds of thousands of ops/sec per shard at sub-millisecond latency.

## Frequently asked questions

**Is a Redis string the same as a C string?**
No. Redis strings are [SDS (Simple Dynamic Strings)](https://redis.io/docs/latest/operate/oss_and_stack/reference/internals/internals-sds/): a header (`len`, `alloc`, `flags`) followed by a byte buffer. They are binary-safe, length-prefixed, and store an `\0` terminator only as a convenience for C interop.

**Can I store a JSON object in a Redis string?**
Yes, and it is a perfectly reasonable pattern when you read the object whole. The Upstash SDK auto-serializes with `JSON.stringify` on `set` and `JSON.parse` on `get`, so `await redis.set("user:42", { name: "Ada" })` and `await redis.get<User>("user:42")` round-trip cleanly. For per-field mutation, prefer a hash or RedisJSON.

**Why does&#32;`SET`&#32;on an existing key destroy its TTL?**
By design. `SET` is an unconditional assignment that overwrites both value and metadata. Pass the `KEEPTTL` flag (Redis 6.0+) to preserve expiration: `SET key newvalue KEEPTTL`. Most client libraries expose this as a `{ keepTtl: true }` option.

**What is the difference between&#32;`SETNX`&#32;and&#32;`SET ... NX`?**
`SETNX` is the original 1.0 command; `SET ... NX` (Redis 2.6.12+) is the modern variant that also accepts `EX`/`PX`/`EXAT` in the same atomic call. Always prefer `SET key value NX PX 30000` for locks because it is atomic. The two-call sequence `SETNX` then `EXPIRE` is not, and will leak locks if your client crashes between them.

**How big can a Redis string actually get in practice?**
The protocol limit is 512 MB but the practical ceiling is lower. Replication, RDB snapshots, and AOF rewrites all serialize the full value, so a 100 MB string lengthens your fsync tail and can starve other commands during BGSAVE. Most production deployments cap individual values at a few megabytes and put larger blobs in object storage with the key holding a reference.

