Ticks - What the f**k!?
By Twade
Concentrated liquidity rests on one foundational idea: the tick. Once ticks click, the rest of Uniswap V4 follows - sqrtPriceX96, tick spacing, swap mechanics, and the gas tricks that make the whole thing fast.
Ticks are the price grid that powers concentrated liquidity in Uniswap V4. Once you understand how they work, a lot of the rest of the protocol - sqrtPriceX96, tick spacing, the way swaps move through liquidity - stops feeling like cryptic naming and starts feeling like a single coherent system.
This piece is the patient walk-through. By the end you'll be able to:
- Define a tick precisely (it's narrower than most explanations suggest)
- Convert between ticks and prices without reaching for a reference
- Explain why
1.0001shows up everywhere, and why that choice was deliberate - Distinguish tick spacing from the gap between adjacent ticks - a distinction that catches a lot of readers out
- Understand what
sqrtPriceX96is, why the pool stores prices that way, and why the number96matters
There's a calculator a few sections down that keeps tick, price, and sqrtPriceX96 in sync - useful for building intuition once the maths is on the table.
This article is V4-flavoured but most of it carries over to v3 unchanged. The places where V4 differs are flagged in-line.
How we ended up with ticks at all
A short detour through v2 and v3 will make the rest of the article much easier to absorb. If you already have these in your head, feel free to skip ahead.
Uniswap v2 used the famous x * y = k constant-product AMM. Every liquidity provider deposited across the entire price curve, from zero to infinity on both axes. Because k is a non-zero constant, the curve never touches either axis - it's infinite and continuous:
This design is beautifully simple, but capital-inefficient: most of that liquidity sits at prices that will essentially never trade.
Uniswap v3 introduced concentrated liquidity. LPs got to pick a price range - "I'll provide liquidity for this ETH/USDC pool between $1,800 and $2,200, and nowhere else." Once liquidity has range bounds, the curve becomes finite: its ends touch the axes.
A finite curve has a useful property: it can be divided into a finite number of segments. Those segments - and the boundaries between them - are where ticks live. That's the entire reason ticks exist.
What a tick actually is
The precise definition first; the rest of this section takes it apart:
A tick is an integer index that labels a specific relative price on the finite price curve. Ticks are evenly spaced, with adjacent integer ticks always exactly one tick-unit apart. They are boundaries, not the curve itself.
A useful mental model is a number line. Each integer on the line is a tick:
Each tick corresponds to one specific price. The line extends in both directions to a finite bound (we'll get to those bounds shortly). The critical point - and the place where most early misconceptions form - is that the price doesn't sit on a tick. The price moves continuously between them.
Insight #1
Ticks are integer-indexed price boundaries on a finite price curve. The pool's price moves continuously between them. Ticks become operationally relevant when one is crossed during a swap - that's when the pool's active liquidity changes.
Where the pool stores its price
The pool does not store its price as a tick. The pool stores its price as a value called sqrtPriceX96 - a 160-bit fixed-point number that can take any representable value, not just the ones that correspond to integer ticks. The currentTick you see exposed on the pool is derived from sqrtPriceX96 using the formula:
currentTick = floor(log_1.0001(price))So if a swap moves the price to a value sitting somewhere between tick 4 and tick 5, the pool stores that exact value in sqrtPriceX96, and currentTick reports back 4. Nothing is rounded, no precision is lost - the tick is acting as a label, not a resting position.
During a swap, ticks matter only at initialized ticks - ticks where some LP has placed the edge of a position. When a swap crosses an initialized tick, the pool's active liquidity L is updated by adding the tick's liquidityNet. Between initialized ticks, only sqrtPriceX96 changes; L stays the same.
A swap progresses end-to-end like this:
- The pool reads the current
sqrtPriceX96and derives the current tick. - It searches for the next initialized tick in the direction of the swap, using
nextInitializedTickWithinOneWord. - Using the current liquidity
Land the swap maths, it computes how far it can pushsqrtPriceX96toward that next initialized tick before the user's input is fully consumed. - If the input runs out first,
sqrtPriceX96is updated and the swap ends. No tick is crossed,Lis unchanged. - If the next initialized tick is reached first, the pool crosses it, updates
L, and continues with whatever input is left. - The loop repeats until either the input is exhausted or
sqrtPriceLimitX96is hit (we'll cover slippage limits in a separate post).
At no point is the price snapped to an integer tick - sqrtPriceX96 always holds the exact value the swap maths produces.
Why sqrtPrice, and why X96?
Two design choices are sitting inside the name sqrtPriceX96, and both are worth understanding.
Why the square root? Most of the interesting equations in concentrated liquidity - figuring out token ratios when adding a position, computing output amounts for a swap, calculating the slippage limit for a price - work in terms of square roots of price rather than price directly. Storing the square root saves the contract from recomputing it on every operation, and it keeps every downstream maths operation in the same number space. We'll derive why the liquidity equations use sqrt(P) in a future post on liquidity maths; for now, take it as: the equations need it, so the storage matches.
Why X96? Solidity has no floating-point numbers. Everything is an integer. The pool's price, though, is almost never an integer - 1.0001 ^ tick produces an irrational number for almost every tick, and we need to preserve as much of that precision as possible. The standard trick in fixed-point arithmetic is to multiply your real-world value by a large power of two and store the result as a giant integer. This is called Q-notation, and Uniswap uses Q64.96:
- 64 bits for the integer part
- 96 bits for the fractional part
- 160 bits total - fits exactly into a
uint160
That last point matters. uint160 is the same width as an Ethereum address, which means storage slots can be packed efficiently, and the type fits comfortably alongside other small fields in structs. A uint256 would have worked but would waste 96 bits per slot and complicate packing.
The 64-bit integer side can represent prices up to roughly 2^64 ≈ 1.8 × 10^19, which more than covers any realistic relative price between two ERC-20s. The 96-bit fractional side gives us ~28 decimal digits of fractional precision - far more than any swap will ever need.
Concretely, to convert a normal price P into its sqrtPriceX96 form:
sqrtPriceX96 = sqrt(P) * 2^96And to go back:
P = (sqrtPriceX96 / 2^96)^2Inside TickMath.sol, neither of these is computed using the literal formula - there are no sqrt or log opcodes in the EVM, and even if there were, they couldn't operate on non-integers. Instead, the library uses a hand-tuned routine of precomputed magic constants and bit shifts to derive sqrtPriceX96 from a tick in constant time, with full integer precision. It's elegant and well worth reading once you're comfortable with the conceptual model - we'll dedicate a whole post to it.
Tick maths: where 1.0001 comes from
The formula you'll see in every Uniswap doc, paper, and library is this:
p(i) = 1.0001 ^ iWhere i is the tick index and p(i) is the relative price at that tick. By "relative price" we mean: how much of Token1 you get per unit of Token0.
Pools always have two tokens, and Uniswap deterministically picks which is Token0 and which is Token1 by lexicographic sort of their contract addresses - the lower address wins Token0. In V4, native ETH is represented by the zero address and is therefore always Token0 in any pool it's part of.
Ticks always express the price of Token0 measured in Token1. The reverse direction is 1 / price, so storing one direction is enough.
Three worked examples will burn the formula in:
Tick = 0
p(0) = 1.0001 ^ 0 = 1The two tokens trade exactly 1:1.
Tick = 10
p(10) = 1.0001 ^ 10 ≈ 1.00100045012At tick 10, 1 unit of Token0 is worth approximately 1.001 units of Token1.
Tick = −10
p(-10) = 1.0001 ^ -10 ≈ 0.99900054979At tick −10, 1 unit of Token0 is worth approximately 0.999 units of Token1.
Insight #2
The base could theoretically be any positive number other than 1. 1.0001 was chosen so that each tick step corresponds to exactly 0.01% of price movement - one basis point. Tying tick indices directly to basis points makes pricing, slippage, and fee-tier reasoning much easier across the entire system.
Tick spacing: a separate concept
This is the most commonly muddled piece of tick terminology.
Tick spacing is not the distance between adjacent ticks.
The distance between adjacent integer ticks is always 1, by definition. Tick 4 and tick 5 are 1 apart. Tick 122 and tick 123 are 1 apart. Always.
Tick spacing is a per-pool parameter with a different job. It says:
"In this pool, LPs can only place position boundaries at ticks that are multiples of this number."
A pool with tickSpacing = 60 doesn't mean the ticks themselves are 60 apart. Every integer tick still exists, and the pool's price can sit at tick 47 perfectly comfortably - LPs can't start or end a position there. Valid LP boundaries in that pool are ticks like ..., -120, -60, 0, 60, 120, 180, ....
When a swap searches for the next initialized tick to potentially cross, it's looking only at tick-spacing-aligned ticks where LPs have placed positions - not at every integer tick. That's where the gas savings come from: fewer possible position boundaries means fewer potential crossings per swap.
Insight #3
Adjacent integer ticks are always 1 apart. Tick spacing is a separate, per-pool constraint that restricts where LP positions can begin and end. Larger tick spacing = fewer possible position edges = cheaper gas on swaps, with the trade-off of less granular LP control.
Tick spacing in v3 vs V4 - an important difference
In Uniswap v3, tick spacing was tightly coupled to the fee tier. There were only four officially supported combinations:
| Fee tier | Tick spacing |
|---|---|
| 0.01% | 1 |
| 0.05% | 10 |
| 0.30% | 60 |
| 1.00% | 200 |
In Uniswap V4, that coupling is gone. Pool creators set tickSpacing independently as part of the PoolKey. You can pair a 0.30% fee with tick spacing 1, or a 0.01% fee with tick spacing 200 - whatever your hook design calls for. This is a bigger deal than it sounds: hooks can ship pools with unusual spacings tuned to specific use cases (e.g. very tight for orderbook-style hooks, very loose for long-tail launchpads).
The bounds - where the curve ends
We've been treating the curve as finite without saying where it stops. Uniswap V4 stores all tick values in an int24 - a signed 24-bit integer with a theoretical range of [-8,388,608, 8,388,607]. The actual enforced range, though, is much tighter:
MIN_TICK = -887272
MAX_TICK = 887272These bounds correspond to the smallest and largest prices sqrtPriceX96 can meaningfully represent without overflow. The matching constants in TickMath.sol are:
MIN_SQRT_PRICE = 4295128739
MAX_SQRT_PRICE = 1461446703485210103287273052203988822378723970342Worth knowing they exist; you don't need to memorise them.
Try it yourself
The calculator below keeps tick, price, and sqrtPriceX96 in sync. Drag the slider, type a tick, type a price - everything updates together. You can toggle tick-spacing snapping to feel how the LP-boundary constraint behaves at different fee tiers.
Tick ↔ Price ↔ sqrtPriceX96
All three values stay in sync as you change any one of them.
TickMath.sol uses exact integer math - at the extreme ends of the tick range your numbers here will drift by a few digits from the real on-chain values, but every interesting tick (i.e. any pool you'd ever swap in) lands on the dot.A few things worth poking at while you're there:
- Set tick to
0and confirm the price is exactly1andsqrtPriceX96is exactly2^96(=79228162514264337593543950336). - Set tick to
10000and note that the price climbs to ~2.72 - that's because1.0001^10000 ≈ e, a small but pleasing mathematical coincidence. - Switch tick spacing to 60 and try setting tick to
47. It will snap to 60. That's the LP-boundary constraint at work. - Visit
MIN_TICKandMAX_TICKand observe how extreme the resulting prices are. That's why the bounds are enforced - anything beyond them is well outside any realistic trading range.
Recap
If you only take four things from this:
- A tick is an integer index labelling a price on a finite curve. Prices live continuously between ticks; the pool stores them as
sqrtPriceX96, not as the tick itself. - Adjacent integer ticks are always 1 apart, and each step is exactly 1 basis point (0.01%) of price movement. That's where the magic number
1.0001comes from. - Tick spacing is not the gap between ticks. It's a per-pool constraint on where LPs can place position boundaries. Larger spacing means cheaper swap gas at the cost of LP granularity.
- In V4, tick spacing is decoupled from fee tier. This unlocks meaningful design space for hooks.
What to read next
This post covered the what and the why. To go further:
sqrtPriceX96and Q64.96 numbers - the next piece in this series. Why the pool stores square roots, how Q-notation fixed-point arithmetic works in Solidity, and howgetSqrtPriceAtTick/getTickAtSqrtPricederive their results using bit shifts and magic constants.TickMath.sol- the canonical reference: github.com/Uniswap/v4-core/blob/main/src/libraries/TickMath.sol. Start withgetSqrtPriceAtTickandgetTickAtSqrtPrice.- The v3 whitepaper - still the best long-form treatment of the underlying maths. The V4 changes are architectural; the tick maths is unchanged.
If anything here didn't click, reach out on X and let me know.
