Chainlink price feeds: how to handle decimal scaling
If you've ever wired up a Chainlink price feed, hit “deploy,” and watched your supposedly safe transaction revert on a collateralization check that should have passed — or, worse, watched it succeed…

If you've ever wired up a Chainlink price feed, hit “deploy,” and watched your supposedly safe transaction revert on a collateralization check that should have passed — or, worse, watched it succeed with a value so absurdly low that the UI rendered “$0.00” — you already know what this article is about. The numbers aren't lying to you. The decimals are.
Chainlink price feeds are the canonical source of on-chain price data across DeFi, and they're one of those integrations that feels like it should take five minutes and quietly consumes an afternoon. The trap is almost always the same: somewhere between the raw int256 answer the oracle returns and the human-readable number your frontend displays, you divided or multiplied in the wrong place, and Solidity — with its integer-only arithmetic — discarded precision before you had a chance to use it.
There is another part of the problem that tends to appear later, when the first bug has already been fixed: scaling correctly can make intermediate values much larger. The safe arithmetic order is usually “multiply first, divide last,” but that does not make multiplication unlimited. In Solidity 0.8 and later, an oversized checked operation reverts. In older Solidity versions, or inside an unchecked block, the same operation can wrap around and produce a completely unrelated value.
The distinction matters. Decimal handling is not only about choosing the right power of ten. It is also about knowing which unit every number uses, how much precision the final result needs, and what happens when the intermediate calculation reaches the limits of the integer type.
The Mechanics of Chainlink Price Feed Precision
Every Chainlink price feed is backed by an on-chain aggregator contract that exposes the AggregatorV3Interface. When you call latestRoundData(), you get back a tuple that includes the price as an int256 — the raw answer — along with a round ID, the timestamp of the last update, and additional round metadata.
The raw answer is not a float. It is an integer scaled by a fixed number of decimals, and that number depends on the feed.
The two patterns you'll see most often look like this:
| Feed type | Example pair | Raw decimals | Sample raw answer | Human-readable value |
|---|---|---|---|---|
| USD-denominated | ETH/USD, BTC/USD, LINK/USD | 8 | 305000000000 | $3,050.00000000 |
| ETH-denominated | TOKEN/ETH | 18 | 1230000000000000 | 0.00123 ETH |
| Exotic or long-tail | Some niche assets | Varies | Feed-specific | Query decimals() |
For an eight-decimal ETH/USD feed, the raw answer 305000000000 means 3050, because:
305000000000 / 10^8 = 3050
The decimal count belongs to the feed, not to the asset in the abstract. ETH/USD and TOKEN/ETH are not required to use the same precision. Even two feeds that ultimately help value the same asset can expose different decimal scales if their quote assets or aggregator configurations differ.
This is where the first gotcha lives: there is no universal Chainlink divisor. You cannot hardcode a “Chainlink divisor of 10^8” and assume it works everywhere. Each aggregator exposes a decimals() function that returns a uint8 describing the scale of its answers.
That value is metadata, but it is also part of the numerical meaning of the answer. Treating the raw answer without its decimal count is like reading a balance without knowing whether the unit is wei, ether, cents, or dollars.
If your code assumes eight decimals for every Chainlink feed, you're one new asset pair away from a production incident.
A practical integration should keep the raw answer and its precision connected. Passing a bare uint256 through several layers of business logic makes it easy for one function to assume eight decimals while another assumes eighteen. A small wrapper, a named structure, or a clearly documented return value is often enough to prevent that class of mistake.
The feed's decimals are usually stable for the lifetime of a particular aggregator, but your protocol may still change the feed address through an upgrade or governance action. That is why the feed address and the feed precision should be treated as part of the same configuration. If one changes, review the other.
Why Solidity Integer Math Causes Precision Loss
Solidity does not have native floating-point arithmetic. There is no double, no general-purpose decimal, and no automatic precision management waiting behind a division operator. Every operation works with integers, and integer division discards the fractional remainder.
That behavior is deterministic, but it is not always intuitive.
Suppose an ETH/USD feed returns the raw answer 305000000000, representing an ETH price of $3,050 with eight feed decimals. If code divides that answer by 1e18 before doing anything else, the result is zero:
305000000000 / 1e18 = 0
The original price was valid. The divisor was simply unrelated to the unit of the feed. Once the division has truncated the value to zero, multiplying it later cannot restore the lost precision.
The same problem appears when the divisor is conceptually correct but is applied too early. Consider a token amount stored in its smallest units. The price and the amount both carry scale information, so dividing one of them before multiplication can throw away a fractional result that would have contributed meaningfully to the final value.
The general rule is straightforward:
1. Keep values in their scaled integer form.
2. Multiply while the relevant precision is still present.
3. Divide once, at the point where you know the desired output unit.
4. Make the output precision explicit.
The third point is the one that prevents the familiar “$0.00” failure. If the raw price is rawPrice, the feed uses priceDecimals, and the token amount is expressed in its smallest units with tokenDecimals, then a whole-unit result can be represented conceptually as:
rawPrice * tokenAmount / 10^(priceDecimals + tokenDecimals)
That expression assumes the desired result is an integer number of quote-asset whole units. If the contract needs the result in a quote token's smallest units, it may need to multiply by the quote token's precision before dividing. There is no single universal formula independent of the output unit.
This is why “normalize the price” is an incomplete instruction. Normalize it to what? Whole dollars? Six-decimal USDC units? Eighteen-decimal fixed-point units? A liquidation comparison may not need human-readable dollars at all. A frontend display usually does.
The overflow side of multiply-first arithmetic
Multiplying first preserves precision, but it also creates a larger intermediate value. For example, rawPrice * tokenAmount can be much larger than either final value, particularly when:
- the feed reports a high-priced collateral asset;
- the token amount is represented in 18-decimal base units;
- the protocol supports very large supplies or deposits;
- several scaling factors are applied before the final division;
- the result is promoted to a wider-looking conceptual unit but remains stored in
uint256.
A very large token supply multiplied by a high-priced collateral value can exceed the maximum value representable by uint256.
The behavior then depends on the Solidity version and the arithmetic context:
- In Solidity 0.8 and later, ordinary arithmetic is checked by default. If the intermediate multiplication or addition overflows, the transaction reverts. It does not silently wrap.
- In Solidity versions before 0.8, arithmetic on unsigned integers wraps modulo
2^256. An overflow can therefore turn a large positive number into a small, apparently valid number. - In Solidity 0.8 and later, an
uncheckedblock restores wrapping behavior for the operations inside that block. Usinguncheckedaround a price calculation without a proven bound can reintroduce the old failure mode deliberately.
So the phrase “the intermediate can wrap around” is only correct for unchecked or pre-0.8 arithmetic. With checked Solidity 0.8+ arithmetic, the immediate result is a revert, which is safer than accepting a corrupted valuation but still a production problem if the path can be triggered by a legitimate deposit or position size.
The solution is not to disable checks merely to make the transaction pass. First establish an upper bound for every operand and for the intermediate product. If the bound is not comfortably below uint256's maximum, change the calculation rather than hiding the failure.
Several approaches are available:
- Reduce one operand before multiplication only when the reduction is mathematically safe and does not discard material precision.
- Use a full-precision multiplication-and-division routine designed to handle a 512-bit intermediate product before reducing the result to
uint256. - Split the calculation into factors whose ranges are controlled, with explicit rounding rules.
- Reject input amounts that cannot be represented safely by the chosen formula.
- Keep the price in a fixed-point format that matches the protocol's actual requirements instead of accumulating unnecessary scale.
- Use
uncheckedonly when an invariant proves that overflow is impossible, and document that invariant next to the block.
The important trade-off is visible here: dividing too early causes precision loss; multiplying without a range analysis creates an overflow risk. Correct decimal scaling requires both precision planning and bounds analysis.
Implementing Dynamic Scaling with AggregatorV3Interface
The clean approach is to query decimals() from the aggregator, obtain the raw answer through latestRoundData(), validate the answer, and carry the feed precision alongside it.
A basic reader usually needs to do the following:
- hold a reference to an
AggregatorV3Interface; - call
latestRoundData(); - read the
int256 answer; - reject non-positive values before converting the answer to an unsigned type;
- inspect
updatedAtif the application has a freshness requirement; - query
decimals()from the same feed; - return the raw price and its decimal count as a pair.
The order of validation matters. Converting a negative int256 answer to uint256 before checking it is not a harmless implementation detail. It can produce a very large unsigned number, turning an invalid oracle answer into something that looks like an enormous price. Validate the signed answer first, then perform the conversion.
A normalized helper can divide the raw answer by 10 ** dec, but that helper must define what it returns. If it returns a whole-unit integer, an eight-decimal price of $3,050 becomes 3050, and any fractional quote-asset amount is discarded. That may be acceptable for a coarse display or a threshold expressed in whole dollars, but it is usually not enough for lending, swaps, collateral valuation, or accounting.
For most protocol calculations, it is better to keep the raw answer and normalize only into a known protocol precision. For example, a protocol might choose a fixed internal scale such as 1e18 and convert a feed answer into that scale:
- if the feed has fewer decimals than the internal scale, multiply by the difference;
- if the feed has more decimals, divide by the difference according to an explicit rounding policy.
That conversion is still subject to overflow checks. Multiplying an answer by 10 ** difference can overflow if the target scale is unnecessarily large or if the feed's answer range is not bounded. A dynamic decimal count does not remove the need to validate the exponent and the result.
It is useful to distinguish three separate values in the code and documentation:
| Value | Meaning | Typical handling |
|---|---|---|
| Raw answer | Integer returned by the aggregator | Keep with the feed's decimal count |
| Feed-normalized price | Raw answer divided by 10^feedDecimals | Useful for human-readable interpretation |
| Protocol-normalized price | Price converted to the protocol's chosen scale | Use for cross-feed comparisons and accounting |
The third value is often what the business logic actually needs. If a protocol compares ETH/USD with another USD-denominated feed, both values should be converted to the same internal scale before comparison. If it compares a TOKEN/ETH price with an ETH/USD price, it must also account for the quote asset. Decimal conversion alone cannot turn an ETH-denominated price into a USD-denominated one.
That distinction prevents a common category error: matching the number of decimals while ignoring the unit. An 18-decimal TOKEN/ETH answer and an 18-decimal USD price are equally precise as integers, but they do not represent the same thing.
When you need to combine a price with a token amount, define the desired output before writing the formula. Suppose:
rawPriceusespriceDecimals;tokenAmountis in the token's smallest units;- the price is quoted in USD;
- the desired result is USD with
quoteDecimalsdecimal places.
The conceptual result is:
rawPrice * tokenAmount * 10^quoteDecimals / (10^priceDecimals * 10^tokenDecimals)
That expression may need to be rearranged to avoid overflow, and it should not be implemented mechanically without checking the ranges of every intermediate. If the desired result is a whole-dollar threshold, quoteDecimals may be zero. If the result is intended to match a six-decimal stablecoin amount, quoteDecimals may be six. The output unit determines the final scale.
Returning the raw answer and decimals together is a small but valuable API decision. It makes the unit visible to the caller and reduces the chance that one caller normalizes with a stale or hardcoded assumption. For a fixed feed address, decimals() can often be read during initialization and stored as configuration, but any code path that allows the aggregator address to change should update or revalidate the associated precision.
Best Practices for Normalizing Price Data in Calculations
Once you have a reliable feed reader, the engineering question becomes where normalization belongs. The answer depends on whether the value is being compared, stored, calculated, or displayed.
1. Compare in raw or protocol-normalized units.
If a liquidation threshold is $1,500 and the feed uses eight decimals, represent that threshold in the same unit as the raw answer — conceptually 1500 * 1e8 — and compare the two integers directly. This avoids an unnecessary division and keeps both sides of the comparison in one scale.
If the protocol supports more than one feed, convert each feed into a shared internal precision first. Do not compare a raw eight-decimal answer with a raw eighteen-decimal answer merely because both are called uint256.
2. Convert at the edge.
Inside the contract, stay in scaled integer units for as long as possible. Normalize when a value crosses into a different boundary:
- a frontend display;
- an event intended for off-chain readers;
- a view function with a documented return scale;
- a comparison against a value stored in another unit;
- a settlement or transfer amount whose token precision is known.
This keeps the business logic explicit. The frontend can format a value with the appropriate number of decimals, currency symbol, and locale without forcing the contract to throw away useful precision.
3. Keep the unit next to the value.
A variable named price is not enough. The code should make it possible to tell whether the value is:
- a raw Chainlink answer;
- a price with eight decimal places;
- an eighteen-decimal fixed-point value;
- an amount in token base units;
- a quote amount in whole units;
- a quote amount in the quote token's base units.
Names such as rawPrice, feedDecimals, amountInTokenUnits, and priceWad are more useful than generic names because they carry part of the numerical contract.
4. Treat decimals() as feed metadata, not as a protocol-wide constant.
A feed's precision should come from the aggregator configuration. If the contract supports a single immutable feed, reading the value once during setup can be reasonable. If the feed address can be upgraded, the decimals must be read again or otherwise validated whenever the configuration changes.
Caching a decimal count is not inherently unsafe. Caching it while allowing the feed address to change independently is. The address and its metadata form one configuration record.
5. Decide how to round.
Integer division floors by default. That is not always the correct economic behavior.
Rounding down may be safer when calculating collateral value, available borrowing capacity, or an amount that the protocol must not overstate. Rounding up may be appropriate for a debt obligation, fee, or minimum payment. The choice should be deliberate and consistent with the risk model.
The issue is especially important when a value is normalized more than once. Repeated floor divisions can accumulate a meaningful error even when each individual truncation looks small. Prefer one well-defined conversion over a chain of intermediate normalizations.
6. Bound the intermediate before multiplying.
The “multiply first, divide last” rule is a precision rule, not an exemption from overflow analysis. Before multiplying, ask:
- What is the maximum possible price returned by the feed?
- What is the maximum token amount accepted by the function?
- How many decimal multipliers are applied?
- Is the result required in whole units or base units?
- Can the product exceed
uint256even if the final quotient would fit? - Does the implementation use checked arithmetic, a full-precision routine, or an
uncheckedblock?
With Solidity 0.8+, checked arithmetic causes an overflow to revert. That is preferable to silent corruption, but a revert can still become a denial-of-service condition if a legitimate user can reach the boundary. With pre-0.8 code or unchecked arithmetic, the same boundary can produce wrapping and a false low value. Neither behavior should be left to chance.
7. Do not normalize just to make a number look familiar.
A price of 305000000000 may look inconvenient, but it is perfectly usable if the comparison threshold is expressed in the same scale. Converting it to 3050 can make logs and interfaces easier to read, but it also removes eight-decimal precision. Human readability is a presentation concern; contract arithmetic needs a unit that preserves the information the protocol relies on.
8. Keep gas considerations subordinate to numerical correctness.
Calling priceFeed.decimals() on every read may be unnecessary for a truly immutable feed, but saving a small amount of gas is not a reason to hardcode a value across a multi-feed system. In many designs, the decimal count can be cached safely during initialization while the price itself is read dynamically.
The more important gas optimization is often avoiding redundant conversions. If a threshold can be stored in the feed's native scale, compare raw values directly instead of repeatedly dividing and multiplying around the same check.
A useful mental model is simple: the raw answer is the number, and decimals() is part of its unit. Your contract should think in that unit until there is a concrete reason to translate it.
Multiply first, divide last. But before you multiply, prove that the intermediate fits.
Avoiding Common Pitfalls with latestRoundData
Three patterns cause a large share of integration bugs, and decimal scaling is only one part of the picture.
Using latestAnswer()
The older latestAnswer() style of access returns only the price. It does not provide the round metadata and timestamp needed for a proper freshness check. If the feed is stale because of network conditions, a configuration problem, or an oracle incident, a contract using only that value has no context for deciding whether the answer is still acceptable.
Use latestRoundData() instead and inspect at least the fields relevant to your application:
answer, after checking that it is positive;updatedAt, for a staleness window;- the round identifiers, when the protocol needs to reason about round continuity or completeness.
A freshness limit should reflect the application's risk tolerance and the behavior expected from the feed. A lending protocol may need stricter handling than a dashboard that merely displays an indicative price. The exact window belongs in the protocol's risk configuration; it should not be copied blindly from an unrelated integration.
Also remember that a timestamp check is not the same as a decimal check. Fresh data can still be interpreted in the wrong unit. You need both: a valid, sufficiently recent answer and the correct scale for that answer.
Assuming a fixed divisor across feeds
Even within one protocol, multiple feeds may use different precisions. ETH/USD is commonly encountered with eight decimals, while a TOKEN/ETH feed may use eighteen. The relevant value is whatever the particular aggregator reports through decimals().
A hardcoded 1e8 in one shared library is especially dangerous because the code may work during initial testing with one asset and fail silently when a second feed is added. The resulting error can be a factor of ten, a factor of one hundred, or much larger. It may not cause a revert; it may simply make collateral appear too valuable or too cheap.
The same problem can occur in off-chain services. An indexer or frontend that assumes every Chainlink answer uses eight decimals can display an incorrect value even when the contract is calculating correctly. The feed address, answer, and decimal count should travel together through the application stack.
Trusting answer without checking its state
A positive answer is necessary, but it is not sufficient for a production-grade integration. Check whether the value is recent enough for the operation being performed. A stale price may still be positive and may still pass a superficial validation.
The protocol should also consider what happens around round transitions. Depending on the feed and the integration, a read may return a value that is technically present but not suitable for the protocol's risk decision. A zero or negative answer should be rejected before any unsigned conversion. An old updatedAt should be rejected according to the configured staleness policy.
When multiple feeds are combined, validate each one independently. A fresh ETH/USD answer does not make a stale TOKEN/ETH answer safe. Nor does a valid price from one feed compensate for a decimal mismatch in another.
Mixing asset decimals with feed decimals
A token's own decimals() value and a Chainlink feed's decimals() value describe different things.
- Token decimals describe how the token amount is represented in its smallest units.
- Feed decimals describe how the price answer is represented.
- Quote-token decimals describe how a resulting payment or balance is represented.
For an 18-decimal token, an amount of 1e18 means one whole token. For an eight-decimal price feed, a raw answer of 1e8 means one whole unit of the quote asset. These scales must be combined intentionally when calculating a value.
A frequent error is to use the token's decimals as if they were the feed's decimals, or to normalize a price correctly and then treat the normalized whole-unit result as though it were a stablecoin base-unit amount. The arithmetic may compile and the transaction may succeed while the economic meaning is wrong.
Ignoring the output scale
Before implementing a conversion, write down the unit of the result. “USD value” is not precise enough. The result might be:
- whole USD;
- USD with eight decimal places;
- six-decimal stablecoin units;
- an internal eighteen-decimal fixed-point number.
The denominator and any final multiplier depend on that decision. A helper that returns uint256 without documenting its scale is an invitation to misuse.
A good wrapper can expose a uniform price representation, but it should also state the representation clearly. For example, an internal priceWad can be useful if every consumer understands that it uses eighteen decimal places. A raw feed value can be more efficient if consumers retain the associated feed scale. The correct choice depends on the rest of the protocol; the dangerous choice is an undocumented one.
Reaching for unchecked to bypass a revert
Unchecked arithmetic sometimes has a legitimate use when an invariant makes the range obvious and removing redundant checks saves gas. It is not a general solution for a multiplication that may exceed uint256.
If a calculation currently reverts because rawPrice * tokenAmount is too large, wrapping the operation in unchecked does not fix the valuation. It replaces a visible failure with a corrupted result. That is particularly dangerous in collateral, liquidation, debt, fee, and share-price calculations, where a wrapped value can influence transfers or solvency decisions.
Use a range proof, a full-precision multiplication-and-division implementation, or a different formula. If the protocol cannot safely represent the requested amount, rejecting that amount is better than accepting an incorrect price.
Finally, if your protocol depends on multiple feeds — collateral in ETH, debt in USD, and perhaps an oracle for an exotic pair — build a small library that wraps AggregatorV3Interface and exposes a uniform price-and-metadata result. The wrapper can centralize answer validation, staleness checks, decimal handling, and conversion rules.
That abstraction is useful only if it preserves the information callers need. A helper that returns a single anonymous integer may hide the very unit information the rest of the code requires. A better interface either returns the raw value with its decimals or returns a clearly documented protocol-normalized value. In both cases, the rounding and overflow behavior should be deliberate.
The bug surface drops when there is one well-tested place for feed normalization, but centralization is not magic. Test the wrapper with feeds using different decimal counts, very small prices, very large prices, zero and negative answers, stale timestamps, maximum supported amounts, and values close to the arithmetic boundary. The test should verify not only that ordinary examples work, but also that unsafe inputs revert or are handled according to the protocol's policy.
Closing thoughts
Decimal handling in Chainlink price feeds is one of those topics that looks small from the outside and quietly turns into a category of bugs the moment a protocol supports more than one asset. The fix is mechanical, but it is not just “divide by the right number.”
Read decimals() from the aggregator. Keep the raw answer tied to that metadata. Distinguish feed decimals from token and quote-token decimals. Multiply before dividing when that preserves the required precision, but calculate the size of the intermediate before committing to the formula. Define the output unit and rounding policy instead of letting integer division decide them accidentally.
And be precise about overflow behavior. Solidity 0.8+ checked arithmetic reverts when a multiplication or addition exceeds the type's range. Silent wrapping belongs to pre-0.8 arithmetic or to code placed inside unchecked. Both cases deserve explicit review, especially in valuation and collateral logic.
Normalize at the boundary between contract math and user-facing data. Inside the protocol, compare values that share a known scale; at the frontend boundary, format them for people. That separation keeps the on-chain calculations exact without forcing every consumer to understand the raw representation.
If the math is solid and the integration behaves correctly under stress, but the protocol still isn't getting the traction you'd expect, the issue is rarely the price feed itself — it's usually the round trip through RPC, indexing, and state sync that adds latency your UI doesn't tolerate, and a broader look at how teams approach protocol launch strategy and discoverability tends to surface what to fix next once the integration layer is solid. None of that matters, though, if a user opens your dApp on launch day and sees “$0.00” in the borrow position box. Precision bugs don't announce themselves; they quietly send liquidity to the next protocol.