Back to Blog 

TL;DR — Quick Summary
- Uniswap V3 stores the square root of the price, not the price, because the change in
sqrt(P)during a swap is linear in the amount traded. Storing the price directly would mean computing a square root on-chain on every swap. sqrtPriceX96is Q64.96 packed into auint160. The 64 and the 96 are both derived, not chosen: the supported price range needs exactly 64 integer bits, and 96 fractional bits keep the intermediate math inside auint256.- Price space is indexed logarithmically. Tick
imeans a price of1.0001^i, so every tick is exactly one basis point from its neighbour at any price level. - Tick spacing bounds swap gas and makes liquidity overflow impossible by construction, via a per-tick cap computed once in the constructor.
uncheckedinLiquidityMath.addDeltais not a gas optimization. Under Solidity 0.8 the arithmetic would panic before V3's own comparison could run, changing the revert behaviour every integrator depends on.
The full walkthrough, with the code written on screen. The article below covers
the same ground if you would rather read it.
Introduction
Open the Uniswap V3 pool contract, look for the variable that holds the price, and you will not find it.
There is no
price. What is there is sqrtPriceX96 — the square root of the price, as a fixed-point number, in a uint160.That reads like something an engineer did to be clever. It is not. It is the single decision that makes the entire swap loop cheap enough to exist, and almost every strange-looking thing in the codebase downstream is a consequence of it.
This is section 2 of Building Uniswap V3, and the first one where you write code. Before you can build a pool you need V3's coordinate system: how price is stored, how price space is indexed, and the guard rails that keep both inside safe bounds.
Why the square root, and not the price
In the previous section the relationship for a swap came out like this: with liquidity
L, adding some amount of token1 moves the price such that the change in sqrt(P) equals that amount divided by L.Read it again, because it is the whole point. The change in the square root of the price is linear in the amount traded. Not the price. The square root.
So if the pool stores
sqrt(P), every step of a swap is one multiply and one divide.If it stored
P directly, the contract would have to compute a square root on every swap, on-chain. Square roots are iterative — Newton's method, several rounds of multiply and divide, each round burning gas — and a swap can cross many ticks, meaning many steps, meaning many square roots.V3 pays that cost exactly once, off-chain, when a human picks a price. The chain only ever sees the result.
Q64.96, and why the type is not an accident
Q64.96 means 64 integer bits and 96 fractional bits. Those sum to 160, which is exactly the uint160 the value is packed into.Neither number was picked for aesthetics.
The 64 comes from the supported range. V3 supports prices from
2^-128 to 2^128. Take the square root of both ends and the range becomes 2^-64 to 2^64. The integer part therefore needs exactly 64 bits — no waste, no shortage.The 96 comes from precision at the bottom of that range. Ninety-six fractional bits leaves 32 bits of precision even when the square root price is at its smallest. Fewer bits and the low end of the range becomes unusably coarse.
And 160 total is the ceiling: it is the largest square root price that still keeps the intermediate multiplications inside a
uint256. Go wider and the math needs 512-bit intermediates everywhere, which is exactly what V3 is trying to avoid.A worked example
Say the price is 2.
sqrt(2) is about 1.4142. Multiply by 2^96 and you get roughly 1.12 × 10^29.That enormous integer is what the pool actually stores. To recover the price you divide by
2^96 and square the result. You will do that squaring constantly when reasoning about V3, so it is worth getting comfortable with it now rather than being surprised by a 29-digit number in a debugger later.Indexing price space with ticks
Concentrated liquidity needs boundaries you can name, so V3 indexes price logarithmically. Tick
i means a price of 1.0001^i.Every tick is one basis point from its neighbour — one hundredth of one percent. Because the spacing is logarithmic, a tick is the same relative step whether the price is 0.0001 or 10,000, which is exactly the property you want for financial ranges. A 1% band is the same number of ticks wide regardless of where it sits.
Some anchors worth memorising:
| Tick | Price |
|---|---|
| 0 | 1 |
| 6,931 | ≈ 2 |
| −6,932 | ≈ 0.5 |
| ±887,272 | the hard bounds |
That last row is the supported price range translated into tick terms.
MIN_TICK is −887,272 and MAX_TICK is +887,272, and they exist because the price range they correspond to is the range in which the fixed-point math is proven safe.The library you are given, and the revert codes you inherit
The conversion between ticks and square root prices lives in
TickMath, and it is handed to you rather than written. It contains about twenty hardcoded magic constants implementing a binary decomposition of the exponent. You use it. You do not retype it.But read it once, top to bottom, and notice its revert codes: a bare
'T' for a tick out of bounds, a bare 'R' for a ratio out of bounds.These single-character strings are not laziness. In 2021 every byte of a revert string cost real deployment gas, and these functions are called from everywhere. Every Uniswap V3 fork you will ever audit inherited them, which means
'T' and 'R' are worth recognising on sight — they will appear in a failing trace years from now with no other context.What tick spacing actually buys
Not every tick is usable. Each fee tier imposes a tick spacing: in a 0.30% pool the spacing is 60, so positions may only use ticks divisible by 60.
The first reason is gas. Every initialized tick a swap crosses is an extra storage write. Coarser spacing means fewer ticks can ever be initialized, which caps how expensive the swap loop can get for any single trade.
But spacing does a second thing, and this one is a safety property.
Overflow made impossible by construction
The pool tracks total in-range liquidity as a
uint128. If every usable tick were allowed to hold the maximum, the sum across them could overflow.So the pool computes a cap, once, in its constructor: round
MIN_TICK and MAX_TICK inward to usable ticks, count how many there are, and divide the uint128 range by that count.For a spacing of 60 that is 29,575 usable ticks and a cap of about
1.15 × 10^34 per tick. Enormous — far beyond any liquidity that will ever exist.Which is the point. The cap is not there to constrain liquidity providers. It is there so that overflow is impossible by construction rather than checked in the hot path.
That pattern is worth stealing well beyond this codebase. The best invariant is the one that cannot be violated, not the one you test for. And it is why a fork that changes tick spacing without recomputing this cap silently reopens a hole the original design had closed — the check that would have caught it was deliberately never written, because it was never needed.
Building something that holds user funds? The Founder Security Sprint is a focused engagement for teams shipping their first protocol. And if you want to work through protocols like this one every week alongside other security engineers, that is Zealynx Insiders.
Working auditors in your corner, all year
Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from the firm behind 42 audits. Founders get a two-day audit session on the $500/year plan.
No spam. Unsubscribe anytime.
unchecked as a correctness requirement
Here is the piece with the sharpest edge in the whole section.
Liquidity is stored unsigned, as a
uint128. Changes to it are signed — a mint adds, a burn subtracts. So you need a function that applies a signed delta to unsigned state and reverts if it would wrap.V3 does it like this. If the delta is negative, subtract it and require the result is strictly less than what you started with. If it is not, the subtraction wrapped, so revert with
'LS' — liquidity sub. Symmetrically for addition, with 'LA'.Now look at that carefully, because there is a trap.
Under Solidity 0.8, that subtraction reverts on its own, with a bare arithmetic panic, before your
require ever runs. The wrap the comparison was written to detect can no longer happen, so the comparison becomes dead code — and the revert reason changes from 'LS' to Panic(0x11).That is not cosmetic. Callers that expect
'LS' get a panic instead. Tests that assert on the revert string break. Integrators that branch on it break.So you wrap the body in
unchecked and let V3's original comparison do the checking.unchecked here is not an optimization. It is a correctness requirement. That is a rule you will apply repeatedly through this module — the same reasoning governs fee growth accumulators, where deliberate overflow is load-bearing rather than tolerated — so it is worth marking now.Three requires between users and the math
The last function is the smallest and the one most easily dismissed.
Every position's range passes through
checkTicks, which enforces exactly three things: lower tick below upper tick ('TLU'), lower tick at least MIN_TICK ('TLM'), upper tick at most MAX_TICK ('TUM').That function is the only gate between user-supplied ticks and the math libraries. Remove any one of those three lines and you can mint a position whose amounts are undefined — not reverting, not zero, but whatever the fixed-point math happens to produce outside its proven range.
Three requires, and every mint, burn and swap for the rest of the protocol runs straight through them.
What the rest of the protocol now leans on
Two things, and everything after this section assumes both.
Price as a square root, in Q64.96, packed into a
uint160. And price space as ticks, one basis point apart, with guard rails at ±887,272 and a per-tick liquidity cap derived from spacing.Next in this series:
SqrtPriceMath, where those square root prices stop being storage and start doing work. Given a price range and an amount, how much of each token does a position actually need? That is where rounding direction starts to matter, and where getting it backwards hands money to the wrong side.FAQ
1. Why does Uniswap V3 store the square root of the price instead of the price?
Because the change in
sqrt(P) during a swap is linear in the amount traded, while the change in P is not. With liquidity L, adding an amount of token1 moves sqrt(P) by exactly that amount divided by L. Storing the square root turns each swap step into one multiply and one divide. Storing the price would force an on-chain square root computation on every step, which is iterative and expensive.2. What does Q64.96 mean, and why those numbers?
Q64.96 is fixed-point with 64 integer bits and 96 fractional bits, summing to the 160 bits of a
uint160. The 64 is derived from the supported price range: prices from 2^-128 to 2^128 become square root prices from 2^-64 to 2^64, needing exactly 64 integer bits. The 96 leaves 32 bits of precision at the bottom of that range, and 160 total is the widest value that keeps intermediate multiplications inside a uint256.3. How do you convert a tick to a price in Uniswap V3?
Tick
i corresponds to a price of 1.0001^i, so the price is 1.0001 raised to the tick index, and the tick is log(price) / log(1.0001). On-chain the conversion goes through the TickMath library, which computes getSqrtRatioAtTick and getTickAtSqrtRatio using a binary decomposition with about twenty hardcoded constants. Tick 0 is a price of 1, tick 6,931 is roughly 2, and the bounds are ±887,272.4. Why is `unchecked` required in `LiquidityMath.addDelta`?
Because V3's own check is a post-hoc comparison — subtract, then require the result got smaller — and under Solidity 0.8 the subtraction reverts with a bare arithmetic panic before that comparison can run. The comparison becomes unreachable and the revert reason silently changes from
'LS' to Panic(0x11), breaking every caller and test that depends on the string. Wrapping the body in unchecked restores the original semantics. It is a correctness requirement, not a gas optimization.5. What is the per-tick liquidity cap for?
It makes overflow of the pool's
uint128 active-liquidity total impossible by construction. The constructor rounds MIN_TICK and MAX_TICK inward to usable ticks, counts them, and divides the uint128 range by that count. For a spacing of 60 that is 29,575 ticks and a cap around 1.15 × 10^34 per tick — far above any real liquidity. The cap exists so no runtime overflow check is needed in the swap loop, which is why forks that alter tick spacing without recomputing it reintroduce a risk the original design had eliminated.Glossary
| Term | Definition |
|---|---|
| sqrtPriceX96 | The square root of the pool price multiplied by 2^96, the Q64.96 fixed-point value V3 stores instead of the price. |
| Q64.96 | Fixed-point format with 64 integer and 96 fractional bits, packed into a uint160. |
| Tick | A discrete price point where tick i means a price of 1.0001^i, so each tick is one basis point from its neighbour. |
| Tick Spacing | The fixed interval constraining which ticks a position may use, bounding swap gas and the per-tick liquidity cap. |
| Fee Tier | The fixed swap fee attached to a pool, which determines its tick spacing. |
| Liquidity Net | The signed liquidity a pool applies to its active total when the price crosses a tick. |
| Rounding Direction | The deliberate per-operation choice of rounding so that truncation always favours the pool. |
Write it yourself
This article is section 2 of Building Uniswap V3, a free 18-section module on the Zealynx Academy. In this one you write
LiquidityMath.addDelta, the per-tick liquidity cap, and checkTicks — and a 9-test suite checks each of them, including the revert strings.If you would rather learn it alongside other engineers, with a live build session every week and office hours where you bring your own code, that is Zealynx Insiders.
Working auditors in your corner, all year
Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from the firm behind 42 audits. Founders get a two-day audit session on the $500/year plan.
No spam. Unsubscribe anytime.
