How SQL Server Stores DECIMAL and NUMERIC, Down to the Bytes

A brass-and-steel machine disassembling a decimal number: a woman engineer feeds a glowing number into the apparatus, which splits it into a small sign token and a compact stack of integer blocks, while four storage bins of increasing size sit in a stepped row, illustrating how SQL Server stores a decimal as a sign byte plus a little-endian integer sized in precision tiers.

Every schema has one: a column declared decimal(38, 4) because someone, at some point, wanted to be safe. Nobody remembers the number that made 38 digits feel necessary, and nobody has dared to shrink it since. The instinct is understandable, but it is not free, and the cost is easiest to see once you know exactly what SQL Server writes to the page when you store a decimal. This post takes a decimal apart on disk, shows the sign-plus-integer layout it actually uses, and explains why precision buys storage in tiers rather than by the digit.

This continues the byte-level storage series alongside strings and UTF-8, the uniqueidentifier, and where each column lives in a record. If you have not watched a row get pulled apart with DBCC PAGE, this older post covers the mechanics. Every byte below is from a SQL Server 2019 instance, and the scripts are included so you can reproduce them.

decimal and numeric are the same type

Start with the least interesting fact, because it clears the decks: decimal and numeric are synonyms.[1] They compile to the same thing, store the same bytes, and there is no performance or storage reason to prefer one keyword over the other. Pick one for consistency and move on. Everything in this post applies equally to both.

A decimal(p, s) is defined by two numbers. Precision p is the total count of significant digits, from 1 to 38. Scale s is how many of those digits sit to the right of the decimal point. So decimal(9, 2) holds up to nine digits, two of them after the point: values from -9999999.99 to 9999999.99. The default when you write bare decimal is decimal(18, 0), which surprises people who expected decimals to keep a fractional part.

Precision buys bytes in tiers

Here is the first thing worth designing around: storage size does not grow one byte per digit. It steps up in four tiers, and every precision inside a tier costs exactly the same.[1]

Precision Storage bytes
1 – 9 5
10 – 19 9
20 – 28 13
29 – 38 17

You can confirm it from the catalog. Four columns, one in each tier:

The practical consequence is that the decimal(38, 4) from the introduction costs 17 bytes per row, while a decimal(9, 4) that covers everything up to about 99,999 costs 5 bytes. On a narrow table with a few million rows, that difference is real, and it repeats in every nonclustered index that carries the column. Scale, notice, does not appear in the size at all: decimal(9, 0) and decimal(9, 8) both cost 5 bytes. Scale changes where the decimal point sits, not how much you store.

The on-disk layout: a sign byte and an integer

Now the part that explains the tiers. SQL Server does not store the digits as text, and it does not store the decimal point at all. It stores the value as an integer, scaled up by 10^s so the fractional part becomes whole, preceded by a single byte for the sign. The point’s position is metadata, fixed by the column’s scale, so it never needs to travel with the data.

Let us prove it. A tiny table, four values in one decimal(9, 2) column, then a raw page dump:

Reading the four records off the page with DBCC PAGE, the five bytes of each decimal(9, 2) value are:

Read the first row. The value 123.45 at scale 2 becomes the integer 123.45 * 100 = 12345. In hexadecimal that is 0x3039, and SQL Server writes it little-endian (least significant byte first), so it lands on the page as 39 30, zero-padded out to the tier’s four-byte mantissa: 39 30 00 00. In front of it sits the sign byte, 01 for positive.

The -123.45 row is the same four mantissa bytes, 39 30 00 00, with the sign byte flipped to 00 for negative. The magnitude is stored identically; only that leading byte carries the sign. 1.00 scales to the integer 100 (0x64), and 0.00 is a positive zero. That leading 01/00 sign byte is why a five-byte tier holds a four-byte integer: one of the five bytes is spent on the sign. The same logic gives the other tiers a 8-, 12-, or 16-byte integer behind their sign byte, which is enough to hold 19, 28, and 38 decimal digits respectively.

Because the stored value is an exact integer, decimal arithmetic is exact. This is the real reason to reach for decimal over float for money and other quantities where a rounding error is a bug rather than a rounding: there is no binary-fraction approximation happening, just an integer with an agreed-upon point.

Precision you never use still costs bytes

Because size is fixed by the declared precision, not by the value, an oversized column pays its tax on every single row regardless of what you put in it. The value 5 stored in decimal(5, 0) and in decimal(38, 0) is the identical number, but not the identical footprint:

Same value, more than three times the storage, purely because of a declaration. This is the concrete cost behind the decimal(38, 4)-to-be-safe habit.

How to pick precision and scale

None of this makes decimal a type to avoid; it is the correct choice for exact fixed-point quantities, and the tiers are coarse enough that right-sizing is easy. A few guidelines that fall out of the layout:

  • Size precision to the real domain, then stop. Count the digits the value can actually reach, add a little headroom, and pick the smallest precision that covers it. Landing at precision 9 instead of 19 halves the storage, from 9 bytes to 5.
  • Mind the tier boundaries. The jumps are at precision 10, 20, and 29. Every precision inside a tier costs the same, so if you need 20 digits there is no storage saving in trimming to 22; both sit in the 13-byte tier. The savings come from staying under a boundary (9 rather than 10, or 19 rather than 20), not from shaving digits within a tier.
  • Scale is free, within your chosen precision. Because scale does not affect size, choose the scale your data needs without worrying about bytes. Just remember scale counts against precision: decimal(9, 4) leaves only five digits for the integer part.
  • Reach for decimal, not float, when exactness matters. Money, tax rates, and measured quantities that must add up want the exact-integer storage decimal provides. float and real are for scientific magnitudes where the binary approximation is acceptable and the range is huge.

The decimal(38, 4) in your schema might be exactly right. But now it is a decision you can defend from the bytes up, rather than a number nobody remembers choosing.

What is the widest decimal you have found in a schema you inherited, and did anyone know why? I would like to hear it. Find me on Bluesky or LinkedIn.

References

  1. decimal and numeric (Transact-SQL) – Microsoft Learn. decimal and numeric are synonyms, precision 1 to 38, and the storage-bytes-by-precision table.