PostgreSQL's float4 and float8 Implementation in C

float4 and float8 are just an IEEE-754 float and double living inside a Datum — but PostgreSQL wraps every operation in overflow checks, declares NaN equal to itself and greater than everything, and lets a GUC change what the output function prints.

float4 (SQL real) and float8 (SQL double precision) are the two IEEE-754 binary floating-point types. They are also the least mysterious types in PostgreSQL: a float4 really is the four bytes of a C float, and a float8 really is the eight bytes of a C double, stuffed directly into a Datum. There is no varlena header, no TOAST, no palloc, no pointer.

That simplicity is exactly why the C is interesting. Because the raw bytes are the value, the interesting decisions have nowhere to hide: they live in the inline helpers in src/include/utils/float.h and the I/O functions in src/backend/utils/adt/float.c. This is a walk through both, based on reading the PostgreSQL source (version 20devel, commit e75fcc9867f).

The catalog entry

Everything about how a value is stored starts in src/include/catalog/pg_type.dat:

text
{ oid => '700', array_type_oid => '1021',
  descr => 'single-precision floating point number, 4-byte storage',
  typname => 'float4', typlen => '4', typbyval => 't', typcategory => 'N',
  typinput => 'float4in', typoutput => 'float4out', typreceive => 'float4recv',
  typsend => 'float4send', typalign => 'i' },
{ oid => '701', array_type_oid => '1022',
  descr => 'double-precision floating point number, 8-byte storage',
  typname => 'float8', typlen => '8', typbyval => 't', typcategory => 'N',
  typispreferred => 't', typinput => 'float8in', typoutput => 'float8out',
  typreceive => 'float8recv', typsend => 'float8send', typalign => 'd' },

Read the fields as a contract:

Fieldfloat4float8Meaning
typlen48fixed width, no length word
typbyvalttthe value is the Datum
typaligni (4)d (8)alignment inside a tuple
typstoragep (default)p (default)not toastable
typcategoryNNnumeric-ish
array oid10211022_float4 / _float8

typstorage is not written, so it takes the catalog default of p — TYPSTORAGE_PLAIN (src/include/catalog/pg_type.h), which is the only sensible choice for a fixed-width by-value type. A float8 cannot be compressed or moved out of line; it is 8 bytes, forever. Note also that float8 carries typispreferred => 't' in category N (as does oid); that flag matters when the parser has to pick a numeric type for an ambiguous operator or literal.

The value is the Datum

Datum is uint64_t (src/include/postgres.h). Converting between a float and a Datum cannot be a plain cast — C does not guarantee that integer and floating-point values use the same registers or calling convention — so postgres.h uses a union to reinterpret the bits:

c
static inline Datum
Float8GetDatum(float8 X)
{
	union
	{
		float8		value;
		int64		retval;
	}			myunion;

	myunion.value = X;
	return Int64GetDatum(myunion.retval);
}

DatumGetFloat4 / Float4GetDatum do the same dance through int32. So PG_RETURN_FLOAT8(x) is a bit-reinterpretation into a word, and PG_GETARG_FLOAT8(0) is the inverse. Nothing is allocated.

Arithmetic is checked, not silent

If the value is just a C float, why not use a + b? Because C’s floating point is quiet: it returns inf on overflow and 0 on underflow, and PostgreSQL wants an error. The wrappers in src/include/utils/float.h add the checks:

c
static inline float4
float4_pl(const float4 val1, const float4 val2)
{
	float4		result;

	result = val1 + val2;
	if (unlikely(isinf(result)) && !isinf(val1) && !isinf(val2))
		float_overflow_error();

	return result;
}

The test is deliberately specific: an infinite result is only an error if neither input was already infinite. 'Infinity'::float8 + 1 is legal; the overflow path is for finite inputs that escaped the range. Division is the busiest case:

c
static inline float4
float4_div(const float4 val1, const float4 val2)
{
	float4		result;

	if (unlikely(val2 == 0.0f) && !isnan(val1))
		float_zero_divide_error();
	result = val1 / val2;
	if (unlikely(isinf(result)) && !isinf(val1))
		float_overflow_error();
	if (unlikely(result == 0.0f) && val1 != 0.0f && !isinf(val2))
		float_underflow_error();

	return result;
}

float_overflow_error() and friends are pg_noinline out-of-line ereport() calls in float.c; the header comment explains that inlining them at every call site would bloat the binary. The _safe variants (float8_pl_safe, float8_div_safe, …) thread an escontext through so the same logic can return a soft error for ErrorSaveContext.

There is even a build-time guard at the top of float.c:

c
#ifdef __FAST_MATH__
#error -ffast-math is known to break this code
#endif

-ffast-math implies -ffinite-math-only, which would make the isinf() checks above unreliable — and PostgreSQL’s float semantics depend on them.

NaN is a first-class value, with an opinion

IEEE-754 says every comparison with NaN is false, and each NaN is unequal to even itself. That is a disaster for ORDER BY, B-tree indexes, and merge joins, which need a total order. PostgreSQL therefore defines its own comparison functions, all in float.h:

c
static inline bool
float4_eq(const float4 val1, const float4 val2)
{
	return isnan(val1) ? isnan(val2) : !isnan(val2) && val1 == val2;
}

static inline bool
float4_lt(const float4 val1, const float4 val2)
{
	return !isnan(val1) && (isnan(val2) || val1 < val2);
}

The rule, spelled out in the header comment, is: all NaNs are equal to each other, and every NaN is larger than every non-NaN. So NaN = NaN is true, NaN > 1 is true, and NaN < 1 is false. The B-tree comparator is built on those predicates (src/backend/utils/adt/float.c):

c
int
float4_cmp_internal(float4 a, float4 b)
{
	if (float4_gt(a, b))
		return 1;
	if (float4_lt(a, b))
		return -1;
	return 0;
}

btfloat4cmp / btfloat8cmp are the SQL-visible comparators, and btfloat4sortsupport / btfloat8sortsupport install a direct DatumGetFloat4-based fast comparator so a sort avoids the fmgr call.

Input

float4in and float8in are thin wrappers over float4in_internal and float8in_internal. The float4 path is not a copy of the float8 path with a smaller type — it deliberately calls strtof, and the comment explains why:

The motivation for using strtof() is to avoid a double-rounding problem: for certain decimal inputs, if you round the input correctly to a double, and then round the double to a float, the result is incorrect…

The comment works through 7.038531e-26, which lands exactly on the midpoint between two floats when routed through a double, and therefore rounds the wrong way. Using strtof rounds the decimal to float directly.

Both functions handle NaN and the infinities themselves, because the C standard does not require strtod to accept all the spellings consistently. They also distinguish ERANGE on a genuine out-of-range value from ERANGE on a denormalized result (which they accept). A non-empty string with trailing junk is a syntax error.

Output depends on a GUC

Here is the genuinely surprising part. What float8out prints is not a property of the value alone — it depends on the extra_float_digits setting (src/backend/utils/adt/float.c):

c
char *
float8out_internal(double num)
{
	char	   *ascii = (char *) palloc(32);
	int			ndig = DBL_DIG + extra_float_digits;

	if (extra_float_digits > 0)
	{
		double_to_shortest_decimal_buf(num, ascii);
		return ascii;
	}

	(void) pg_strfromd(ascii, 32, ndig, num);
	return ascii;
}

When extra_float_digits > 0 (the default is 1, per src/backend/utils/misc/guc_parameters.dat), PostgreSQL uses a Ryu-based shortest-round-trip algorithm, double_to_shortest_decimal_buf from src/common/shortest_dec.c. Otherwise it falls back to a fixed precision of DBL_DIG + extra_float_digits (15 plus the GUC) via pg_strfromd. float4out mirrors this with FLT_DIG and float_to_shortest_decimal_buf.

Because the result depends on a GUC, these functions cannot be IMMUTABLE. They are marked STABLE in src/include/catalog/pg_proc.dat:

text
{ oid => '215', descr => 'I/O',
  proname => 'float8out', provolatile => 's', prorettype => 'cstring',
  proargtypes => 'float8', prosrc => 'float8out' },

That provolatile => 's' is, in fact, the subject of this tree’s tip commit — “Mark GUC-dependent type output functions as STABLE”.

Binary I/O bypasses text entirely: float8send/float8recv use pq_sendfloat8/pq_getmsgfloat8 from libpq/pqformat.c, which write and read the raw IEEE bytes in network order.

The float aggregates use Youngs-Cramer

AVG(), VARIANCE() and STDDEV() over floats do not accumulate sum(X) and sum(X^2). The comment above float8_accum in float.c explains that the schoolbook formula (N*sum(X^2) - sum(X)^2) / N^2 subtracts two huge, nearly equal numbers and loses most of its precision. Instead the transition state is a 3-element float8 array [N, Sx, Sxx], where Sx = sum(X) and Sxx = sum((X - Sx/N)^2), updated incrementally. The parallel combine step generalizes cleanly:

text
N = N1 + N2
Sx = Sx1 + Sx2
Sxx = Sxx1 + Sxx2 + N1 * N2 * (Sx1/N1 - Sx2/N2)^2 / N;

Note that N is stored as a float8 to avoid defining a bespoke transition type; the comment concedes that accuracy is fine until N exceeds about 2^52.

Look at it yourself

The catalog and the comparison semantics are both directly observable:

sql
SELECT typname, typlen, typbyval, typalign, typstorage, typispreferred
FROM pg_type WHERE typname IN ('float4', 'float8');

-- NaN equals itself and sorts last
SELECT 'NaN'::float8 = 'NaN'::float8, 'NaN'::float8 > 1e308;

-- the GUC changes the printed digits, not the stored value
SHOW extra_float_digits;
SET extra_float_digits = 3;
SELECT 0.1::float8;         -- more digits, same 8 bytes
SELECT pg_column_size(0.1::float8), pg_column_size(0.1::float4);

pg_column_size reports 8 and 4 (plus, in a real row, whatever alignment padding the tuple layout imposes — float8 needs 8-byte alignment, float4 only 4). There is no \d+ trick to see here, because there is nothing variable-length to inspect.

What it all means

  • float4/float8 are by-value, fixed-width, and never toasted. The value is the raw IEEE bits reinterpreted into a Datum through a union.
  • Every arithmetic operator is a checked wrapper, not a bare +. Overflow and underflow on finite inputs raise errors; existing infinities pass through.
  • NaN has a defined place in the total order: equal to itself and greater than everything. That is what lets B-trees and sorts treat floats as a normal ordered type.
  • Output is GUC-dependent. extra_float_digits selects Ryu shortest-round-trip output, which is why the output functions are STABLE.
  • float4 input uses strtof, not strtod, specifically to dodge a double-rounding bug.

Further reading

  • src/backend/utils/adt/float.c — I/O, operators, comparators, aggregates.
  • src/include/utils/float.h — checked arithmetic and NaN-aware comparisons.
  • src/include/postgres.h — DatumGetFloat4/Float8GetDatum unions.
  • src/include/common/shortest_dec.h, src/common/shortest_dec.c — Ryu output.
  • src/include/catalog/pg_type.dat — the float4/float8 catalog entries.

PostgresInternalsFloat4Float8