Compaction strategies: STCS, LCS, TWCS, and how to pick

What it is

An LSM tree writes immutable sorted files (SSTables) and never updates in place, so a key's history accumulates across files: an insert here, an update there, a tombstone somewhere else. Compaction is the background process that merges those files, discards superseded values, drops expired tombstones, and reorganises the result so reads stay fast.

A compaction strategy is the policy deciding which files to merge and when. That policy is the single largest tuning decision in an LSM store, because it fixes where you sit on the three amplifications (see LSM trees vs B-trees):

StrategyWrite ampRead ampSpace ampShape it suits
STCS size-tieredLow (4-10x)HighUp to 2xWrite-heavy, insert-mostly
LCS leveledHigh (10-30x)LowLow (~1.1x)Read-heavy, update-heavy
TWCS time-windowedLowest (~1-3x)Low for time queriesLowTime series with TTL
UCS unifiedTunable between the aboveTunableTunableNewer Cassandra, one knob

What it is confused with: compaction is not garbage collection and it is not defragmentation, though it does both. It is primarily a read-optimisation process: the reason you pay write amplification to merge files is to bound how many files a read must consult. A store that never compacted would have fast writes and unusable reads.

The second confusion, and the one that causes production incidents: compaction is foreground work wearing background clothes. It competes with live traffic for disk bandwidth, CPU and page cache. "It runs in the background" describes its scheduling, not its cost.

The problem it solves

Without compaction, three things degrade without bound.

Read amplification. Every flushed memtable is another file a read might have to check. After a day of writes you have hundreds of SSTables, and even with bloom filters a point read pays a filter check per file, while a range scan opens an iterator per overlapping file and merges them.

Space. An overwritten value is still on disk in an older file. A row updated 40 times occupies 40 copies. For an update-heavy workload the live dataset can be a small fraction of the bytes stored.

Tombstones. A delete writes a marker rather than removing data, and the marker plus every value it shadows persist until a compaction merges them together. Until then, a range scan over the deleted range reads every tombstone to return nothing.

The strategies differ in how they trade the cost of fixing these against the cost of doing the fixing.

Mechanics

Size-tiered (STCS)

Group SSTables into tiers by size. When a tier accumulates min_threshold files (default 4 in Cassandra), merge them into one file, which lands in the next tier up.

Tier 0 (fresh flushes, ~64 MB each):
   [A] [B] [C] [D]   -> 4 files reached, merge ->  [ABCD]  (~256 MB)

Tier 1 (~256 MB):
   [ABCD] [EFGH] [IJKL] [MNOP]  -> merge ->  [A..P]  (~1 GB)

Tier 2 (~1 GB):
   ...

Each byte is rewritten roughly once per tier, and tiers grow geometrically, so write amplification is about log_4(dataset / memtable), typically 4 to 10x. That is the lowest of the classic strategies, which is why STCS is the write-heavy default.

Two costs follow directly from the structure.

Files within a tier overlap in key range, so a point read may need to check one file per tier and a range scan must merge across all of them. Read amplification grows with the number of tiers.

The space cost is the operational one. Merging four 1 GB files writes a new 4 GB file before deleting the originals, so the peak disk requirement is the merge inputs plus the merge output. For the largest tier that is the dominant fraction of the dataset:

Dataset: 4 TB, largest tier holds most of it.
Major compaction peak: existing 4 TB + new copy up to 4 TB = 8 TB required.

A size-tiered store needs up to 50 percent free disk to survive its own largest compaction, and running out mid-compaction leaves a node unable to compact, which is a downward spiral: files accumulate, reads slow, and the only fix requires the disk space you do not have.

Leveled (LCS)

Organise files into levels. Within a level (above L0), files have non-overlapping key ranges, so a key exists in at most one file per level. Each level is roughly 10x the size of the one above.

L0:  [A-M] [D-Z] [B-Q]      <- overlapping (fresh flushes), checked in full
L1:  [A-F] [G-M] [N-S] [T-Z]   <- disjoint, 300 MB total
L2:  [A-B] [C-D] ... [Y-Z]     <- disjoint, 3 GB total
L3:  ...                        <- disjoint, 30 GB total

Compaction picks a file from level N and merges it with the overlapping files in level N+1. Since level N+1 is 10x larger, one file typically overlaps about 10 files there, so each merge rewrites about 11 files' worth of data to advance one file. That is the source of the ~10x per level, and with several levels the total is 10 to 30x.

What you buy:

  • Read amplification of roughly one file per level. A point read binary-searches file metadata per level, checks a bloom filter, reads one block. A range scan opens at most one iterator per level rather than one per file.
  • Space amplification around 1.1x, because at most 10 percent of the data is duplicated between a level and the one below at any moment. This is the best of the three by a wide margin.
  • Predictable compaction I/O, in small pieces, rather than occasional huge merges.

The failure mode is L0 backup. L0 files overlap, so every read checks all of them. If flushes outpace L0-to-L1 compaction, L0 grows, reads slow, and the engine eventually throttles or stalls writes on purpose (level0_slowdown_writes_trigger, level0_stop_writes_trigger in RocksDB). Under sustained heavy write load, leveled compaction is the strategy most likely to stall, precisely because its write amplification is highest.

Time-windowed (TWCS)

For time-series data with a TTL. Group SSTables by the time window their data falls in (an hour, a day), compact within a window using size-tiered, and never compact across windows.

Window 2026-08-01:  [file] [file]  -> compacted once -> [2026-08-01]
Window 2026-08-02:  [file] [file]  -> compacted once -> [2026-08-02]
Window 2026-08-03:  [f] [f] [f]    <- current window, still receiving writes
...
Window 2026-05-01:  [2026-05-01]   <- entirely past TTL: DROP THE WHOLE FILE

Two properties make this dramatically better for its workload.

Expiry is a file delete. When every row in a file is past its TTL, the file is dropped whole. No merge, no tombstone processing, no rewrite. Compare with STCS or LCS, where expired data is removed by merging files that mostly contain live data, meaning you rewrite gigabytes to reclaim megabytes.

Old data is written once and left alone. A file from last Tuesday is compacted within its window and never touched again, so write amplification approaches 1 to 3x, the lowest of any strategy.

And a range query over a time window reads only the files for that window, because file metadata records the window and everything else is excluded without reading anything. That is why the time-series example on the LSM page saw range scans go from 47 ms under leveled compaction to 6 ms under time-windowed.

The requirements are strict and TWCS is actively harmful when they are not met:

  • Data must arrive roughly in timestamp order. A late write lands in an old window, which forces that window to compact again.
  • The TTL must be uniform across the table, or files never fully expire.
  • No updates or deletes of old data, for the same reason.

A single row written with a timestamp from six months ago keeps a whole window alive. The Cassandra documentation warns about exactly this, and the practical guard is to reject or route out-of-window writes at the application layer rather than hoping.

Unified (UCS)

Cassandra 5.0's UnifiedCompactionStrategy parameterises the space with a single knob w: negative values behave like leveled, positive like size-tiered, zero in between, and the parameter can differ per level. It is the acknowledgement that STCS and LCS are two points on a continuum rather than distinct designs. Worth knowing by name; the decision framework below is unchanged.

A worked example: three tables, three strategies, one cluster

A telemetry platform on Cassandra. One 18-node cluster, three tables with genuinely different shapes, and the team had left all three on the default STCS.

Table 1: raw_metrics. Time-series points, 30-day TTL, append-only, 1.1 million writes per second, queried by device and time range.

Table 2: device_state. Current state per device, 4 million devices, updated roughly every 30 seconds, read constantly by the dashboard. Heavy overwrite of the same keys.

Table 3: alert_history. Append-only alerts, no TTL, read rarely, ~40,000 writes per day.

Symptoms before any change:

disk used (all tables):        41 TB across 18 nodes (2.3 TB/node)
disk provisioned:              3.6 TB/node
p99 read on device_state:      340ms      <- dashboard visibly slow
p99 range read on raw_metrics: 2.1s
compaction backlog:            growing on 6 nodes
one node had failed to compact for 9 days: 3.4 TB used of 3.6 TB

The near-full node is the STCS space-amplification failure. It could not run its largest compaction because it lacked room for the output, so files accumulated, which used more space, which made compaction less possible.

Diagnosis per table.

raw_metrics was the disk hog: 34 of the 41 TB. Under STCS, expired data was being removed by merging huge files that were mostly live data. The team measured that reclaiming roughly 300 GB of expired rows was rewriting about 2.8 TB.

device_state was the read problem. Four million keys updated every 30 seconds means each key had many versions spread across tiers, and STCS's overlapping files meant a point read checked several. Bloom filters helped and could not eliminate the tier count.

alert_history was fine and always would be. Low volume, append-only, read rarely.

The changes:

-- Time series with a uniform TTL: TWCS, window sized so a table has 20-30 windows.
ALTER TABLE raw_metrics WITH compaction = {
  'class': 'TimeWindowCompactionStrategy',
  'compaction_window_unit': 'DAYS',
  'compaction_window_size': 1
} AND default_time_to_live = 2592000;      -- 30 days -> 30 windows

-- Overwrite-heavy and read-heavy: LCS. Pay write amp, buy read amp and space amp.
ALTER TABLE device_state WITH compaction = {
  'class': 'LeveledCompactionStrategy',
  'sstable_size_in_mb': 160
};

-- Append-only, low volume, rarely read: STCS is correct. Leave it.

Measured after six weeks (one full TTL cycle for raw_metrics):

                              before        after
disk used (total)             41 TB         19.4 TB    (-53%)
disk on the worst node        3.4 TB        1.2 TB
p99 read device_state         340ms         28ms       (-92%)
p99 range read raw_metrics    2.1s          210ms      (-90%)
compaction I/O (cluster)      ~4.1 GB/s     ~0.9 GB/s  (-78%)
compaction backlog            growing       zero

Disk halved and compaction I/O dropped 78 percent, and no data was deleted. The entire gain came from stopping the cluster rewriting data it did not need to rewrite. On raw_metrics, TWCS turned "merge 2.8 TB to reclaim 300 GB" into "drop a file," and the 78 percent compaction I/O reduction is that change showing up cluster-wide.

The device_state improvement went the other way on write amplification: LCS writes more than STCS. That was the correct trade, because the table's write volume was small (4 million rows every 30 seconds is about 130,000 writes per second, an eighth of raw_metrics) and its read volume was the product's critical path.

The lesson the team took, and the one worth carrying: compaction strategy is a per-table decision, and the default is right for exactly one of three common shapes. They had one cluster, one default, and three workloads. Nothing about the symptoms pointed at compaction; they presented as "we need more disk" and "the dashboard is slow."

Production evidence

Cassandra ships all four strategies and its documentation recommends per-workload selection, with STCS as the default, LCS for read-heavy and update-heavy tables, and TWCS for time series. The guidance that STCS may require 50 percent free disk headroom for a major compaction appears in the operations documentation and is the most consequential single sentence in it.

TWCS was contributed by Jeff Jirsa (Crowdstrike) and became the recommended time-series strategy in Cassandra 3.0.11+, replacing DTCS (Date-Tiered), which had been the earlier attempt and was deprecated for being fragile with out-of-order writes. That lineage matters: TWCS is the second design for this problem, and its predecessor failed on exactly the requirement that still applies (in-order arrival).

RocksDB implements leveled as the default and universal (its size-tiered analogue) as an option, and its tuning guide is organised around choosing between them by which amplification you can afford. The RocksDB wiki's measured amplification figures per style are the best public numbers available.

ScyllaDB's Incremental Compaction Strategy (ICS) exists specifically to address STCS's space amplification: it splits the large tiers into fragments so a major compaction never needs the full duplicate copy, bringing peak space overhead down from roughly 2x to a small constant. That a competing implementation built a whole strategy around this one problem confirms it is the binding constraint in practice.

Cassandra 5.0's UnifiedCompactionStrategy (CEP-26) unifies STCS and LCS under one parameter, on the reasoning stated in the proposal that they are points on a continuum and that operators were choosing between them without a way to sit in between.

The debate

The decision framework, stated as a rule rather than "it depends":

  • Time series with a uniform TTL and in-order arrival: TWCS. Not a close call. The ability to expire by dropping whole files rather than merging is worth more than any other consideration.
  • Read-heavy or update-heavy, where the same keys are rewritten: LCS. You pay write amplification and you buy low read amplification and low space amplification, and for an overwrite workload the space saving alone often justifies it.
  • Write-heavy, insert-mostly, rarely read: STCS. Lowest write amplification, and its weaknesses (read amp, space amp) are the things this workload does not care about.
  • On Cassandra 5.0+ and unsure: UCS, tuned toward whichever end matches.

The strongest counter-argument to LCS is that it is the strategy most likely to stall under load. Its write amplification is 10 to 30x, so a write burst that STCS absorbs can back up L0 in a leveled store, and the engine throttles writes deliberately to prevent read amplification exploding. If your write rate is anywhere near the disk's sustained throughput divided by 20, LCS is a risk, and that arithmetic is worth doing before switching.

The counter-argument to TWCS is its fragility. It requires in-order arrival, a uniform TTL, and no updates to old data, and violating any of them degrades it to something worse than STCS: windows that never expire, repeated recompaction of old windows, and files that outlive their purpose. My position is that TWCS is correct when the requirements hold and that you should enforce them at the application layer rather than assume, because a single backfill job writing historical timestamps silently undoes the benefit and nothing alerts.

Should you ever run a major compaction manually? nodetool compact merges everything into one file. It reclaims maximum space and it produces one enormous SSTable that STCS will not touch again until three more files of similar size exist, which for a large table means never. On STCS, a manual major compaction is close to a one-way door, and the standard advice is to avoid it and let the strategy work. The legitimate uses are one-off: reclaiming space after a large deletion, before decommissioning, or when changing strategy anyway.

Is per-table tuning worth the operational complexity? It is one line of DDL per table and it is the highest-leverage change available in an LSM store. The worked example halved disk usage. The complexity argument would carry more weight if the alternative were free, and it is not: the default is a choice too, made without knowing your workload.

Follow-up Q&A

"Why does STCS need 50 percent free disk?"

Because a merge writes its output before deleting its inputs. Merging the largest tier means holding the existing files plus the new merged file simultaneously, and for the largest tier that is most of the dataset. So peak usage approaches 2x live data. Running out mid-compaction is self-reinforcing: the node cannot compact, files accumulate, space usage grows, and reads slow. ScyllaDB's incremental strategy exists to break exactly this by fragmenting large tiers so the duplicate is bounded.

"Why is leveled compaction's write amplification so high?"

Because levels are disjoint. To move one file from L(n) to L(n+1), you must merge it with every overlapping file in L(n+1), and since that level is 10x larger, one file typically overlaps about 10. So advancing one file rewrites roughly 11 files' worth of data, and a byte pays that at each level it descends. What you buy is that a key is in at most one file per level, so reads check one file per level instead of many per tier.

"When is TWCS wrong?"

When data does not arrive in timestamp order, when the TTL is not uniform, or when old data is updated or deleted. Any of the three keeps windows alive past their expiry, and then you have STCS with extra constraints and worse behaviour. The concrete failure: a backfill job writing rows with six-month-old timestamps lands them in old windows, those windows recompact, and files that should have been dropped stay. I would enforce in-window arrival at the application layer rather than trust it.

"Compaction is falling behind. What do you do?"

First identify which strategy and which table, because the answers differ. Under LCS, the tell is L0 file count growing and the engine throttling; the levers are more compaction threads, higher compaction throughput limits, a larger memtable so flushes are less frequent, or switching to a lower-amplification strategy. Under STCS, the tell is usually disk pressure preventing large merges; the lever is disk headroom or moving to LCS. And in both cases the honest possibility is that the write rate exceeds what the hardware supports at that amplification, in which case the answer is a different strategy or more nodes, not a tuning parameter.

"Should you run nodetool compact?"

Generally no. On STCS it produces one huge SSTable that will not be compacted again until three more of similar size appear, which effectively freezes it, and inside that file are tombstones that now cannot be dropped because no compaction will process them. Legitimate one-off uses exist: reclaiming space after a mass deletion, or before decommissioning a node. As routine maintenance it makes the problem it appears to fix worse over time.

"How do you choose a TWCS window size?"

Target 20 to 30 windows over the TTL. With a 30-day TTL, a one-day window gives 30. Too few windows (a 7-day window over a 30-day TTL) means coarse expiry, so you retain data well past its TTL and each file is large. Too many (an hourly window over 30 days is 720) means a lot of small files and more metadata to check per query. The 20-to-30 figure is Cassandra's own guidance and it holds up.

Common misconceptions

"Compaction is background work, so it is free." It competes with foreground traffic for disk bandwidth, CPU and page cache, and when it cannot keep up the engine stalls writes on purpose. In the worked example compaction was consuming 4.1 GB/s of cluster disk bandwidth before tuning.

"Leveled compaction is strictly better because read and space amplification are lower." It has the highest write amplification of the classic strategies and is the most likely to stall under a write burst. It is better for read-heavy and update-heavy tables and worse for write-heavy ones.

"TWCS is for any table with a TTL." It needs a uniform TTL, in-order arrival, and no updates to old data. With mixed TTLs, files never fully expire and you get the constraints without the benefit.

"A bigger disk fixes compaction problems." It buys headroom for STCS's space amplification and changes nothing about read amplification or the I/O cost of merging. The worked example halved disk usage by changing strategy, and adding disk would have delayed the same problem by a quarter.

"You should periodically run a major compaction to clean up." On STCS it creates a single file that the strategy will never compact again, freezing any tombstones inside it. It is a one-off tool, not maintenance.

Interview delivery note

Say this verbatim: "Compaction strategy is a per-table decision and the default is right for exactly one workload shape. Time series with a uniform TTL gets TWCS, because expiry becomes dropping a whole file instead of merging gigabytes to reclaim megabytes. Overwrite-heavy and read-heavy gets leveled. Insert-mostly and rarely-read keeps size-tiered." A decision rule with the reasoning attached, rather than a description of three algorithms.

The senior-versus-staff separator is STCS's space amplification as an operational cliff. A senior engineer explains the three strategies and their amplification profiles. A staff engineer knows that a size-tiered store needs up to 50 percent free disk to run its own largest compaction, that running out is self-reinforcing (cannot compact, so files accumulate, so more space is used), and that this presents as "we need more disk" rather than as a compaction problem.

The second signal is naming TWCS's preconditions unprompted and saying you would enforce them in the application. Anyone can recommend TWCS for time series; knowing that one backfill job with historical timestamps silently undoes the entire benefit, with no alert, is the part that comes from having run it.

Further reading

  • Cassandra documentation, "Compaction," for the per-strategy configuration and the disk headroom guidance.
  • Jeff Jirsa's writing on TWCS and its introduction, including why DTCS was deprecated and what TWCS requires of the data.
  • RocksDB wiki, "Leveled Compaction" and "Universal Compaction," for measured amplification figures per style.
  • CEP-26, Cassandra's Unified Compaction Strategy proposal, for the argument that STCS and LCS are points on one continuum.