Ticks: WTF!?

Conversations exploring the latest Uniswap V4 protocols, hooks and industry news.

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.0001 shows 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 sqrtPriceX96 is, why the pool stores prices that way, and why the number 96 matters

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:

The Uniswap v2 constant-product curve x*y=k
From Uniswap Hook Incubator course materials

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.

The Uniswap v3 finite price curve, with both ends touching the axes
From Uniswap Hook Incubator course materials

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:

A number line with ticks marked at integer positions from -9 to +9, with bucketed liquidity above
From Uniswap Hook Incubator course materials

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.

A swap moving the price between two adjacent ticks; sqrtPriceX96 updates continuously
From Uniswap Hook Incubator course materials

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:

  1. The pool reads the current sqrtPriceX96 and derives the current tick.
  2. It searches for the next initialized tick in the direction of the swap, using nextInitializedTickWithinOneWord.
  3. Using the current liquidity L and the swap maths, it computes how far it can push sqrtPriceX96 toward that next initialized tick before the user's input is fully consumed.
  4. If the input runs out first, sqrtPriceX96 is updated and the swap ends. No tick is crossed, L is unchanged.
  5. If the next initialized tick is reached first, the pool crosses it, updates L, and continues with whatever input is left.
  6. The loop repeats until either the input is exhausted or sqrtPriceLimitX96 is 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^96

And to go back:

P = (sqrtPriceX96 / 2^96)^2

Inside 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 ^ i

Where 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 = 1

The two tokens trade exactly 1:1.

Tick = 10

p(10) = 1.0001 ^ 10 ≈ 1.00100045012

At tick 10, 1 unit of Token0 is worth approximately 1.001 units of Token1.

Tick = −10

p(-10) = 1.0001 ^ -10 ≈ 0.99900054979

At 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 tierTick 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 =  887272

These 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 = 1461446703485210103287273052203988822378723970342

Worth 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.

-100,0000+100,000
Tick
Price T1/T0
Price T0/T1
1
sqrtPriceX96
79228162514264337593543950336
% move
0 bps · 0.00%
Spacing
Tick number line - dot = current tick, marks = LP-valid ticks at current spacing
Heads-up. Calculations use JS double-precision floats and are rounded for display. The on-chain implementation in 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:

  1. Set tick to 0 and confirm the price is exactly 1 and sqrtPriceX96 is exactly 2^96 (= 79228162514264337593543950336).
  2. Set tick to 10000 and note that the price climbs to ~2.72 - that's because 1.0001^10000 ≈ e, a small but pleasing mathematical coincidence.
  3. Switch tick spacing to 60 and try setting tick to 47. It will snap to 60. That's the LP-boundary constraint at work.
  4. Visit MIN_TICK and MAX_TICK and 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:

  1. 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.
  2. 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.0001 comes from.
  3. 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.
  4. In V4, tick spacing is decoupled from fee tier. This unlocks meaningful design space for hooks.

This post covered the what and the why. To go further:

  • sqrtPriceX96 and 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 how getSqrtPriceAtTick / getTickAtSqrtPrice derive 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 with getSqrtPriceAtTick and getTickAtSqrtPrice.
  • 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.