BUG #19771: to_char(): NUL byte in result for formats with a trailing decimal point (regression from ec352b37d)

From: PG Bug reporting form <noreply(at)postgresql(dot)org>
To: pgsql-bugs(at)lists(dot)postgresql(dot)org
Cc: gowthamdoma18(at)gmail(dot)com
Subject: BUG #19771: to_char(): NUL byte in result for formats with a trailing decimal point (regression from ec352b37d)
Date: 2026-10-18 20:40:57
Message-ID: 19771-4f173b891525020c@postgresql.org
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-bugs

The following bug has been logged on the website:

Bug reference: 19771
Logged by: Gowtham Reddy
Email address: gowthamdoma18(at)gmail(dot)com
PostgreSQL version: 19rc1
Operating system: Linux x86_64
Description:

Hello,

On master (tested at 45fc51fcbf45), to_char() for numbers returns a text
value containing a NUL byte when the format has a decimal point with no
digit positions after it:

SELECT encode(textsend(to_char(7, '9.')), 'hex');
-- master: 203700 REL_19: 2037
SELECT encode(textsend(to_char(7, '9.PL')), 'hex');
-- master: 2037002b REL_19: 2037
SELECT length(to_char(7, '9.')), octet_length(to_char(7, '9.'));
-- master: 2, 3
SELECT to_char(7, '9.') = ' 7';
-- master: false REL_19: true
SELECT encode(textsend(to_char('100'::numeric, 'FM999.')), 'hex');
-- master: 31303000

The NUL byte is also written to tables: CREATE TABLE t AS SELECT
to_char(7, '9.PL') stores 2037002b. The existing regression query in
numeric.sql (SELECT to_char('100'::numeric, 'FM999.')) returns '100\0' on
master but still passes, because psql stops printing at the NUL.

Bisected to:

ec352b37d Replace formatting.c's fixed-size output buffers with
StringInfos.

Its parent 28c5d0724 returns 2037 for the second query.

The underlying problem is older. In NUM_numpart_to_char(), the "Write
Digits" branch copies *Np->input_p even when the number string is already
exhausted, so it copies the terminating '\0'. With the old fixed-size
buffer that NUL ended the C string, so in released branches everything
after the trailing decimal point is silently dropped, including the sign:

SELECT to_char(-7, '9.MI'); -- REL_19: '7' (sign lost)
SELECT to_char(-7, '9.PR'); -- REL_19: '<7' (closing bracket lost)
SELECT to_char(7, '9.PL'); -- REL_19: ' 7' (PL lost)
SELECT to_char(7, '9."x"'); -- REL_19: ' 7' (literal lost)

With the StringInfo output buffer the NUL is kept, and the suffix appears
after it.

Proposed patch (attached, v1-0001): do not emit the string terminator as a
digit. It also adds a regression test to numeric.sql, which fails without
the fix. The core change is:

--- a/src/backend/utils/adt/formatting.c
+++ b/src/backend/utils/adt/formatting.c
@@ -5659,7 +5659,7 @@ NUM_numpart_to_char(NUMProc *Np, int id)
NUM_EMITC('0');
}
}
- else
+ else if (*Np->input_p != '\0')
{
NUM_EMITC(*Np->input_p); /*
Write DIGIT */
Np->num_in = true;

With this, master returns ' 7', ' 7+', '7-', '<7>', ' 7x' for the queries
above, ' 1,234' for to_char(1234, '9,999.'), and '100' for the numeric.sql
case. Formats without a trailing decimal point are unchanged ('9.9',
'FM9.9', '99.99PL', '99PR'). make check passes (239/239) on master
45fc51fcbf45 with the patch.

This changes the result of e.g. to_char(-7, '9.MI') from '7' to '7-'
compared with released branches. I think that is the correct result, but
you may prefer to handle the back-branches differently.

A regression test that would have caught it:

SELECT octet_length(to_char(7, '9.')), to_char(-7, '9.MI'),
to_char(-7, '9.PR');

We found this with a cross-version differential fuzzer (random to_char,
to_number and to_date calls on REL_19_STABLE vs master), written for a
university course project (CS-5187).

Regards,
Gowtham

Responses

Browse pgsql-bugs by date

  From Date Subject
Next Message Gowtham Reddy 2026-10-18 20:47:07 Re: BUG #19771: to_char(): NUL byte in result for formats with a trailing decimal point (regression from ec352b37d)
Previous Message PG Bug reporting form 2026-10-18 13:58:03 BUG #19770: CREATE TABLE (LIKE ...) PARTITION BY copies a NO INHERIT not-null constraint; NULLs then go into par