Subject: Re: Truncate logs by max_log_size

From: Álvaro Herrera <[email-masked]>
Date: Thu, 5 Feb 2026 11:22:19 +0100

On 2026-Feb-05, Jim Jones wrote:

> On 05/02/2026 03:17, Fujii Masao wrote:
> > At least I'm not for now. So please feel free to work on the patch if
> > you'd like!
> 
> If nobody is planning to work on this, I can take a look at it next week.

That'd be swell.

My only comment at this point is that the proposed GUC name is not
great.  I think it should be something like log_statement_max_length, or
something like that.  Reading just the thread subject, people would
imagine this is about the size of the log file.

Another point is that the current patch does strlen() twice on each
query.  It might be better to do away with need_truncate_query_log() and
have a single routine that both determines whether the truncation is
needed and returns the truncated query if it is.  If it returns NULL
then caller assumes it's not needed.

-- 
Álvaro Herrera               48°01'N 7°57'E  —  https://www.EnterpriseDB.com/

---

From: Jim Jones <[email-masked]>
Date: Thu, 5 Feb 2026 12:53:22 +0100

> imagine this is about the size of the log file.

+1 for log_statement_max_length

> then caller assumes it's not needed.

I'll take a look at this.

Another thing that we could adjust is the default/disable value: I
personally find 0 to disable the feature a bit confusing. 0 might be
interpreted as "log 0 bytes", and the user ends up with the full
statement logged. What about -1?

Best, Jim

---

From: Jim Jones <[email-masked]>
Date: Fri, 6 Feb 2026 08:50:03 +0100

> imagine this is about the size of the log file.

Done. GUC changed to log_statement_max_length.

> then caller assumes it's not needed.

Done. Now truncate_query_log() returns a palloc'd truncated copy of the
statement if truncation is needed, and NULL otherwise.

== issue with issue with empty STATEMENT ==

This issue was mentioned by Fujii upthread. It is now fixed:

postgres=# SET log_statement TO 'all';
SET
postgres=# SET log_statement_max_length TO 3;
SET
postgres=# SELECT 1/0;
ERROR:  division by zero

Log entries:

2026-02-05 17:54:47.521 CET [570568] LOG:  statement: SEL
2026-02-05 17:54:47.521 CET [570568] ERROR:  division by zero
2026-02-05 17:54:47.521 CET [570568] STATEMENT:  SELECT 1/0;

== default value ==

To be consistent with other parameters, such as
log_parameter_max_length, the default (disabled) value is now -1.

== tests ==

Added new tests for -1 and multi-byte characters.

Best, Jim

From d12a565220d1827079c033507919ac5229004ce7 Mon Sep 17 00:00:00 2001
From: Jim Jones <[email-masked]>
Date: Thu, 5 Feb 2026 18:00:13 +0100
Subject: [PATCH v7] Add log_statement_max_length GUC to limit logged statement
  size

When log_statement is enabled, queries can be arbitrarily long and may
consume significant disk space in server logs. This patch introduces a
new GUC, log_statement_max_length, which limits the maximum byte length
of logged statements.

A value greater than zero truncates each logged statement to the given
number of bytes. The default is -1, which disables truncation and logs
full statements. If specified without units, the value is interpreted
as bytes.
---
 doc/src/sgml/config.sgml                      | 18 ++++++++++
 src/backend/tcop/postgres.c                   | 11 ++++--
 src/backend/utils/error/elog.c                | 34 +++++++++++++++++++
 src/backend/utils/misc/guc_parameters.dat     | 10 ++++++
 src/backend/utils/misc/guc_tables.c           |  1 +
 src/backend/utils/misc/postgresql.conf.sample |  2 ++
 src/bin/pg_ctl/t/004_logrotate.pl             | 24 +++++++++++++
 src/include/utils/elog.h                      |  2 ++
 src/include/utils/guc.h                       |  1 +
 9 files changed, 101 insertions(+), 2 deletions(-)

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 5560b95ee6..d9d708d7b9 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -8235,6 +8235,24 @@ log_line_prefix = '%m [%p] %q%u@%d/%a '
       </listitem>
      </varlistentry>
 
+     <varlistentry id="guc-log-statement-max-length" xreflabel="log_statement_max_length">
+      <term><varname>log_statement_max_length</varname> (<type>integer</type>)
+      <indexterm>
+       <primary><varname>log_statement_max_length</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        If greater than zero, each statement logged by <xref linkend="guc-log-statement"/>
+        is truncated to at most this many bytes.
+        <literal>-1</literal> (the default) disables truncation.
+        If this value is specified without units, it is taken as bytes.
+        Only superusers and users with the appropriate <literal>SET</literal>
+        privilege can change this setting.
+       </para>
+      </listitem>
+     </varlistentry>
+
      </variablelist>
     </sect2>
      <sect2 id="runtime-config-logging-csvlog">
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index 02e9aaa6bc..14265ecd08 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -72,6 +72,7 @@
 #include "tcop/pquery.h"
 #include "tcop/tcopprot.h"
 #include "tcop/utility.h"
+#include "utils/elog.h"
 #include "utils/guc_hooks.h"
 #include "utils/injection_point.h"
 #include "utils/lsyscache.h"
@@ -1023,11 +1024,15 @@ exec_simple_query(const char *query_string)
 	bool		was_logged = false;
 	bool		use_implicit_block;
 	char		msec_str[32];
+	char	   *truncated_query = NULL;
+	const char *query_log;
 
 	/*
 	 * Report query to various monitoring facilities.
 	 */
 	debug_query_string = query_string;
+	truncated_query = truncate_query_log(query_string);
+	query_log = truncated_query ? truncated_query : query_string;
 
 	pgstat_report_activity(STATE_RUNNING, query_string);
 
@@ -1072,7 +1077,7 @@ exec_simple_query(const char *query_string)
 	if (check_log_statement(parsetree_list))
 	{
 		ereport(LOG,
-				(errmsg("statement: %s", query_string),
+				(errmsg("statement: %s", query_log),
 				 errhidestmt(true),
 				 errdetail_execute(parsetree_list)));
 		was_logged = true;
@@ -1370,7 +1375,7 @@ exec_simple_query(const char *query_string)
 		case 2:
 			ereport(LOG,
 					(errmsg("duration: %s ms  statement: %s",
-							msec_str, query_string),
+							msec_str, query_log),
 					 errhidestmt(true),
 					 errdetail_execute(parsetree_list)));
 			break;
@@ -1381,6 +1386,8 @@ exec_simple_query(const char *query_string)
 
 	TRACE_POSTGRESQL_QUERY_DONE(query_string);
 
+	if (truncated_query)
+		pfree(truncated_query);
 	debug_query_string = NULL;
 }
 
diff --git a/src/backend/utils/error/elog.c b/src/backend/utils/error/elog.c
index e6a4ef9905..ffabbfbceb 100644
--- a/src/backend/utils/error/elog.c
+++ b/src/backend/utils/error/elog.c
@@ -3805,6 +3805,40 @@ write_stderr(const char *fmt,...)
 	va_end(ap);
 }
 
+/*
+ * truncate_query_log - truncate query string if needed for logging
+ *
+ * Returns a palloc'd truncated copy if truncation is needed,
+ * or NULL if no truncation is required.
+ */
+char *
+truncate_query_log(const char *query)
+{
+	size_t		query_len;
+	size_t		truncated_len;
+	char	   *truncated_query;
+
+	/* Check if truncation is disabled (-1) or no query string provided */
+	if (!query || log_statement_max_length < 0)
+		return NULL;
+
+	query_len = strlen(query);
+
+	/*
+	 * No need to allocate a truncated copy if the query is shorter
+	 * than log_statement_max_length.
+	 */
+	if (query_len <= (size_t) log_statement_max_length)
+		return NULL;
+
+	/* Truncate at multibyte character boundary */
+	truncated_len = pg_mbcliplen(query, query_len, log_statement_max_length);
+	truncated_query = (char *) palloc(truncated_len + 1);
+	memcpy(truncated_query, query, truncated_len);
+	truncated_query[truncated_len] = '\0';
+
+	return truncated_query;
+}
 
 /*
  * Write errors to stderr (or by equal means when stderr is
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index f0260e6e41..4864dbbbb2 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -1777,6 +1777,16 @@
   options => 'log_statement_options',
 },
 
+{ name => 'log_statement_max_length', type => 'int', context => 'PGC_SUSET', group => 'LOGGING_WHAT',
+  short_desc => 'Sets the maximum length in bytes of logged statements.',
+  long_desc => '-1 means no truncation.',
+  flags => 'GUC_UNIT_BYTE',
+  variable => 'log_statement_max_length',
+  boot_val => '-1',
+  min => '-1',
+  max => 'INT_MAX / 2',
+},
+
 { name => 'log_statement_sample_rate', type => 'real', context => 'PGC_SUSET', group => 'LOGGING_WHEN',
   short_desc => 'Fraction of statements exceeding "log_min_duration_sample" to be logged.',
   long_desc => 'Use a value between 0.0 (never log) and 1.0 (always log).',
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index 13c569d879..dbcb25e130 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -544,6 +544,7 @@ int			log_min_duration_statement = -1;
 int			log_parameter_max_length = -1;
 int			log_parameter_max_length_on_error = 0;
 int			log_temp_files = -1;
+int			log_statement_max_length = -1;
 double		log_statement_sample_rate = 1.0;
 double		log_xact_sample_rate = 0;
 char	   *backtrace_functions;
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index c4f92fcdac..3cc4399026 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -642,6 +642,8 @@
                                         # bind-parameter values to N bytes;
                                         # -1 means print in full, 0 disables
 #log_statement = 'none'                 # none, ddl, mod, all
+#log_statement_max_length = -1          # max length of logged statements
+                                        # -1 disables truncation
 #log_replication_commands = off
 #log_temp_files = -1                    # log temporary files equal or larger
                                         # than the specified size in kilobytes;
diff --git a/src/bin/pg_ctl/t/004_logrotate.pl b/src/bin/pg_ctl/t/004_logrotate.pl
index 7b19f86467..dd1aa21f4c 100644
--- a/src/bin/pg_ctl/t/004_logrotate.pl
+++ b/src/bin/pg_ctl/t/004_logrotate.pl
@@ -135,6 +135,30 @@ check_log_pattern('stderr', $new_current_logfiles, 'syntax error', $node);
 check_log_pattern('csvlog', $new_current_logfiles, 'syntax error', $node);
 check_log_pattern('jsonlog', $new_current_logfiles, 'syntax error', $node);
 
+# Verify truncation works with ASCII
+$node->append_conf('postgresql.conf', "log_statement = 'all'\nlog_statement_max_length = 20\n");
+$node->reload();
+$node->psql('postgres', "SELECT '123456789ABCDEF'");
+check_log_pattern('stderr',  $new_current_logfiles, "SELECT '123456789ABC", $node);
+check_log_pattern('csvlog',  $new_current_logfiles, "SELECT '123456789ABC", $node);
+check_log_pattern('jsonlog', $new_current_logfiles, "SELECT '123456789ABC", $node);
+
+# Verify -1 disables truncation (logs full query)
+$node->append_conf('postgresql.conf', "log_statement_max_length = -1\n");
+$node->reload();
+$node->psql('postgres', "SELECT '123456789ABCDEF'");
+check_log_pattern('stderr',  $new_current_logfiles, "SELECT '123456789ABCDEF'", $node);
+check_log_pattern('csvlog',  $new_current_logfiles, "SELECT '123456789ABCDEF'", $node);
+check_log_pattern('jsonlog', $new_current_logfiles, "SELECT '123456789ABCDEF'", $node);
+
+# Verify multibyte character handling (must not produce invalid UTF-8)
+$node->append_conf('postgresql.conf', "log_statement_max_length = 12\n");
+$node->reload();
+$node->psql('postgres', "SELECT '🐘test'");
+check_log_pattern('stderr',  $new_current_logfiles, "SELECT '", $node);
+check_log_pattern('csvlog',  $new_current_logfiles, "SELECT '", $node);
+check_log_pattern('jsonlog', $new_current_logfiles, "SELECT '", $node);
+
 $node->stop();
 
 done_testing();
diff --git a/src/include/utils/elog.h b/src/include/utils/elog.h
index a12b379e09..aabee73aa4 100644
--- a/src/include/utils/elog.h
+++ b/src/include/utils/elog.h
@@ -494,6 +494,7 @@ extern PGDLLIMPORT int Log_destination;
 extern PGDLLIMPORT char *Log_destination_string;
 extern PGDLLIMPORT bool syslog_sequence_numbers;
 extern PGDLLIMPORT bool syslog_split_messages;
+extern PGDLLIMPORT int log_statement_max_length;
 
 /* Log destination bitmap */
 #define LOG_DESTINATION_STDERR	 1
@@ -508,6 +509,7 @@ extern void log_status_format(StringInfo buf, const char *format,
 extern void DebugFileOpen(void);
 extern char *unpack_sql_state(int sql_state);
 extern bool in_error_recursion_trouble(void);
+extern char *truncate_query_log(const char *query);
 
 /* Common functions shared across destinations */
 extern void reset_formatted_start_time(void);
diff --git a/src/include/utils/guc.h b/src/include/utils/guc.h
index bf39878c43..8043d8fa45 100644
--- a/src/include/utils/guc.h
+++ b/src/include/utils/guc.h
@@ -300,6 +300,7 @@ extern PGDLLIMPORT int client_min_messages;
 extern PGDLLIMPORT int log_min_duration_sample;
 extern PGDLLIMPORT int log_min_duration_statement;
 extern PGDLLIMPORT int log_temp_files;
+extern PGDLLIMPORT int log_statement_max_length;
 extern PGDLLIMPORT double log_statement_sample_rate;
 extern PGDLLIMPORT double log_xact_sample_rate;
 extern PGDLLIMPORT char *backtrace_functions;
-- 
2.43.0

---

From: Fujii Masao <[email-masked]>
Date: Fri, 3 Jul 2026 00:49:24 +0900

On Fri, May 15, 2026 at 7:48 PM Jim Jones <[email-masked]> wrote:
>
> rebase due to recent changes in src/test/modules/test_misc/meson.build

I reviewed the patch again and made a few additional changes. The updated
patch is attached. Barring any objections, I'm thinking to commit it.

         If greater than zero, each statement logged by
-        <xref linkend="guc-log-statement"/> or
-        <xref linkend="guc-log-min-duration-statement"/> is truncated to
-        at most this many bytes.
+        <xref linkend="guc-log-statement"/>,
+        <xref linkend="guc-log-min-duration-statement"/>,
+        <xref linkend="guc-log-min-duration-sample"/>, or
+        <xref linkend="guc-log-transaction-sample-rate"/>
+        is truncated to at most this many bytes.

Since this parameter also affects statements logged by
log_min_duration_sample and log_transaction_sample_rate, I added
those parameters to the description.

+        This setting does not affect statements logged because of
+        <xref linkend="guc-log-min-error-statement"/>.

I also clarified that this parameter does not affect statements logged
by log_min_error_statement.

+static char *

Since truncate_query_log() is only used in postgres.c, I moved it
from elog.c and made it a static function.

-ok( $node->log_contains(
- qr/statement: SELECT '123456789ABC(?!D)/, $log_offset),
+ok($node->log_contains(qr/statement: SELECT '123456789ABC$/m, $log_offset),

I tightened the test to verify that the truncated statement ends exactly
at the 20th byte.

-# character (the 🐘 emoji is 4 bytes; with limit=12 it must be kept whole
-# and the following 't' must not appear).
-note "Multibyte truncation respects character boundaries";
+# character when the byte limit falls in the middle of it.
+SKIP:

Using an emoji directly in the test seems fragile and the behavior
may depend on the test platform. To make the test more robust, I changed
it to construct the multibyte character with pack() and run the test
only when the server encoding is UTF-8.

Regards,

-- 
Fujii Masao

---

From: Jim Jones <[email-masked]>
Date: Thu, 2 Jul 2026 20:47:48 +0200

Hi Fujii

> patch is attached. Barring any objections, I'm thinking to commit it.

The changes seem reasonable to me.
Thanks for taking care of it!

Best, Jim

---

From: Fujii Masao <[email-masked]>
Date: Fri, 3 Jul 2026 09:56:04 +0900

> Thanks for taking care of it!

Thanks for the review! I've pushed the patch.

While working on it, I found a few possible follow-up improvements.

(1)
When log_statement is enabled, executing a prepared statement logs the
prepared query in a DETAIL message. For example:

    =# SET log_statement_max_length TO 10;
    =# PREPARE test AS SELECT * FROM pgbench_accounts WHERE aid = $1;
    LOG:  statement: PREPARE te
    =# EXECUTE test(1);
    LOG:  statement: EXECUTE te
    DETAIL:  prepare: PREPARE test AS SELECT * FROM pgbench_accounts
WHERE aid = $1;

Should log_statement_max_length also apply to such query string in the
DETAIL message?

(2)
When a bind parameter is truncated by
log_parameter_max_length, an ellipsis (...) is appended:

    =# SET log_parameter_max_length TO 5;
    =# SELECT $1::text \bind 'abcdefghijk' \g
    LOG:  execute <unnamed>: SELECT $1::text
    DETAIL:  Parameters: $1 = 'abcde...'

Would it make sense for log_statement_max_length to append an
ellipsis as well, so that users can easily tell when a statement has
been truncated?

(3)

truncate_query_log() uses strlen() only to determine whether the
query exceeds log_statement_max_length. Since the query can be very
large, would it be better to use

    strnlen(query, log_statement_max_length + MAX_MULTIBYTE_CHAR_LEN)

instead, to avoid scanning the entire string?

Regards,

-- 
Fujii Masao

---

From: Fujii Masao <[email-masked]>
Date: Fri, 3 Jul 2026 13:24:21 +0900

> Thanks for the review! I've pushed the patch.

The buildfarm member prion reported the failure [1].

It looks like the failure occurs because prion runs with
log_error_verbosity = verbose, which inserts the SQLSTATE between
"LOG:" and the message text. The log_statement_max_length = 0 test
expected "statement:" to appear immediately after "LOG:", so it failed
even though the server correctly logged an empty statement body.

The attached patch fixes the test.

Regards,

[1] https://buildfarm.postgresql.org/cgi-bin/show_log.pl?nm=prion&dt=2026-07-03%2002%3A23%3A06

-- 
Fujii Masao

---

From: Jim Jones <[email-masked]>
Date: Fri, 3 Jul 2026 09:34:22 +0200

> Thanks for the review! I've pushed the patch.

Awesome. Thanks!

> ellipsis as well, so that users can easily tell when a statement has

+1
Nice additions -- the feature gap is obvious, IMHO.

Are you planning to work on it? I'm drowning in work right now and can
only jump on it next week.

> instead, to avoid scanning the entire string?

I'm not so sure about this one. At this point, isn't "query" already \0
terminated? I'm also wondering if it could affect pg_mbcliplen() down
the road, since strnlen() can return a different value
(log_statement_max_length + MAX_MULTIBYTE_CHAR_LEN) on large queries --
not tested yet.

Thanks!

Best, Jim

---

From: Fujii Masao <[email-masked]>
Date: Sat, 4 Jul 2026 00:06:44 +0900

> The attached patch fixes the test.

I've pushed the patch, and confirmed that prion is green again.

Regards,

-- 
Fujii Masao

---

From: Fujii Masao <[email-masked]>
Date: Sat, 4 Jul 2026 00:46:46 +0900

> only jump on it next week.

I don't have plans to work on those at the moment, so please feel free
to take them on if you have time!

> (log_statement_max_length + MAX_MULTIBYTE_CHAR_LEN) on large queries --

Yes, "query" should already be NUL-terminated here. The reason for
using strnlen() is not to handle an unterminated string, but to avoid
scanning the entire query when it's very large and we only need to
know whether it exceeds log_statement_max_length.

I think it's fine to pass the bounded length to pg_mbcliplen().
It only needs enough input to find a multibyte-safe clipping point at
or before log_statement_max_length, i.e., it doesn't need the full
query length. The extra MAX_MULTIBYTE_CHAR_LEN bytes provide enough
lookahead to handle a multibyte character boundary correctly.

- query_len = strlen(query);
+ query_len = strnlen(query,
+ (size_t) log_statement_max_length + MAX_MULTIBYTE_CHAR_LEN);

Regards,

-- 
Fujii Masao

---

From: Jim Jones <[email-masked]>
Date: Sat, 4 Jul 2026 13:01:51 +0200

> + (size_t) log_statement_max_length + MAX_MULTIBYTE_CHAR_LEN);

All right, thanks for the explanation.
I'll give it a try next week.

Have a nice weekend!

Best, Jim

---

From: Jim Jones <[email-masked]>
Date: Wed, 8 Jul 2026 19:02:40 +0200

Hi Fujii

> + (size_t) log_statement_max_length + MAX_MULTIBYTE_CHAR_LEN);

Attached 0001 addressing the points you made:

* appending ellipsis to the query to indicate truncation (docs and tests
also updated accordingly). A side effect of this change is that setting
the parameter to 0 logs an ellipsis, which is not zero in length, but is
arguably correct, as it indicates that the whole query has been
truncated - just noting.
* use of strnlen to avoid scanning the whole query.
* truncation of prepared statements in DETAIL.

While working on this I noticed some redundant checks to call
truncate_query_log(), e.g. in exec_execute_message():

if (log_statement_max_length >= 0)
	truncated_source = truncate_query_log(sourceText);

truncate_query_log() already returns NULL when query logging is disabled
or when no truncation is needed:

/* Truncation is disabled when the limit is negative */
if (!query || log_statement_max_length < 0)
	return NULL;

So I'd argue that we don't need any checks in the caller.

- char	   *truncated_source = NULL;
-
- if (log_statement_max_length >= 0)
-	truncated_source = truncate_query_log(sourceText);
+ char	   *truncated_source = truncate_query_log(sourceText);

0002 attached does this.

Thoughts?

BTW, should I open a new CF entry for this?

Best, Jim

From a47ccafc334e8b508f117afdfc5ce3266e0ed8fa Mon Sep 17 00:00:00 2001
From: Jim Jones <[email-masked]>
Date: Wed, 8 Jul 2026 18:29:09 +0200
Subject: [PATCH v1 1/2] Improve log_statement_max_length truncation

Apply log_statement_max_length consistently when logging prepared
statements. Previously, the prepared query emitted in the DETAIL
message for EXECUTE was not truncated, even though the corresponding
statement logged by log_statement was.

Also append an ellipsis to truncated statements, so it is immediately
apparent when a statement has been truncated.

This also avoid scanning the entire query string when determining
if truncation is needed by using strnlen() instead of strlen() in
truncate_query_log().

Author: Jim Jones <[email-masked]>
Co-authored-by: Fujii Masao <[email-masked]>
---
 doc/src/sgml/config.sgml                      |  4 +-
 src/backend/tcop/postgres.c                   | 16 +++++---
 .../t/014_log_statement_max_length.pl         | 37 +++++++++++++++----
 3 files changed, 42 insertions(+), 15 deletions(-)

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 9172a4c5c95..f4782261e17 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -8525,7 +8525,9 @@ log_line_prefix = '%m [%p] %q%u@%d/%a '
         <xref linkend="guc-log-min-duration-statement"/>,
         <xref linkend="guc-log-min-duration-sample"/>, or
         <xref linkend="guc-log-transaction-sample-rate"/>
-        is truncated to at most this many bytes.
+        is truncated to at most this many bytes. When a statement is
+        truncated, an ellipsis (<literal>...</literal>) is appended to
+        indicate that truncation has occurred.
         A value of zero causes statements to be logged with an empty body.
         <literal>-1</literal> (the default) logs statements in full.
         If this value is specified without units, it is taken as bytes.
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index ce18df820cd..b0947fd7298 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2553,8 +2553,9 @@ check_log_duration(char *msec_str, bool was_logged)
  * truncate_query_log
  *		Truncate query string if needed for logging
  *
- * Returns a palloc'd truncated copy if truncation is needed,
- * or NULL if no truncation is required.
+ * Returns a palloc'd copy of the query truncated for logging, with an
+ * ellipsis appended if truncation occurs, or NULL if no truncation is
+ * required.
  */
 static char *
 truncate_query_log(const char *query)
@@ -2567,7 +2568,7 @@ truncate_query_log(const char *query)
 	if (!query || log_statement_max_length < 0)
 		return NULL;
 
-	query_len = strlen(query);
+	query_len = strnlen(query, (size_t) log_statement_max_length + MAX_MULTIBYTE_CHAR_LEN);
 
 	/*
 	 * No need to allocate a truncated copy if the query is shorter than
@@ -2578,9 +2579,10 @@ truncate_query_log(const char *query)
 
 	/* Truncate at a multibyte character boundary */
 	truncated_len = pg_mbcliplen(query, query_len, log_statement_max_length);
-	truncated_query = (char *) palloc(truncated_len + 1);
+	truncated_query = (char *) palloc(truncated_len + 4);
 	memcpy(truncated_query, query, truncated_len);
-	truncated_query[truncated_len] = '\0';
+	memcpy(truncated_query + truncated_len, "...", 3);
+	truncated_query[truncated_len + 3] = '\0';
 
 	return truncated_query;
 }
@@ -2608,7 +2610,9 @@ errdetail_execute(List *raw_parsetree_list)
 			pstmt = FetchPreparedStatement(stmt->name, false);
 			if (pstmt)
 			{
-				errdetail("prepare: %s", pstmt->plansource->query_string);
+				char *truncated_stmt = truncate_query_log(pstmt->plansource->query_string);
+
+				errdetail("prepare: %s", truncated_stmt ? truncated_stmt : pstmt->plansource->query_string);
 				return 0;
 			}
 		}
diff --git a/src/test/modules/test_misc/t/014_log_statement_max_length.pl b/src/test/modules/test_misc/t/014_log_statement_max_length.pl
index b1ce6068f5d..1239eff4a93 100644
--- a/src/test/modules/test_misc/t/014_log_statement_max_length.pl
+++ b/src/test/modules/test_misc/t/014_log_statement_max_length.pl
@@ -22,7 +22,7 @@ $node->psql(
 	'postgres', "
 	SET log_statement_max_length TO 20;
 	SELECT '123456789ABCDEF';");
-ok($node->log_contains(qr/statement: SELECT '123456789ABC$/m, $log_offset),
+ok($node->log_contains(qr/statement: SELECT '123456789ABC...$/m, $log_offset),
 	"ASCII query truncated at 20 bytes");
 
 # Verify -1 logs statement in full (closing quote must be present).
@@ -51,7 +51,7 @@ SKIP:
 		SET client_encoding TO 'UTF8';
 		SET log_statement_max_length TO 11;
 		$mbquery");
-	ok($node->log_contains(qr/statement: SELECT 'AA$/m, $log_offset),
+	ok($node->log_contains(qr/statement: SELECT 'AA...$/m, $log_offset),
 		"multibyte truncation at character boundary");
 }
 
@@ -62,8 +62,8 @@ $node->psql(
 	'postgres', "
 	SET log_statement_max_length TO 0;
 	SELECT '123456789ABCDEF';");
-ok($node->log_contains(qr/statement:\s*$/m, $log_offset),
-	"0 logs an empty statement body");
+ok($node->log_contains(qr/statement: ...\s*$/m, $log_offset),
+	"0 logs statement body with ellipsis");
 
 # Verify truncation via the extended query protocol (execute message).
 # With log_statement_max_length = 20, a 24-byte query should end
@@ -75,7 +75,7 @@ $node->psql(
 	SET log_statement_max_length TO 20;
 	SELECT '123456789ABCDEF' \\bind \\g");
 ok( $node->log_contains(
-		qr/execute <unnamed>: SELECT '123456789ABC$/m, $log_offset),
+		qr/execute <unnamed>: SELECT '123456789ABC...$/m, $log_offset),
 	"extended protocol execute truncated at 20 bytes");
 
 # Verify extended protocol also respects -1 (no truncation; closing quote
@@ -102,14 +102,35 @@ $node->psql(
 	SET log_statement_max_length TO 20;
 	SELECT '123456789ABCDEF' \\bind \\g");
 ok( $node->log_contains(
-		qr/parse <unnamed>: SELECT '123456789ABC$/m, $log_offset),
+		qr/parse <unnamed>: SELECT '123456789ABC...$/m, $log_offset),
 	"parse duration entry truncated");
 ok( $node->log_contains(
-		qr/bind <unnamed>: SELECT '123456789ABC$/m, $log_offset),
+		qr/bind <unnamed>: SELECT '123456789ABC...$/m, $log_offset),
 	"bind duration entry truncated");
 ok( $node->log_contains(
-		qr/execute <unnamed>: SELECT '123456789ABC$/m, $log_offset),
+		qr/execute <unnamed>: SELECT '123456789ABC...$/m, $log_offset),
 	"execute duration entry truncated");
 
+note "Truncate prepared statement query in DETAIL";
+$log_offset = -s $node->logfile;
+$node->psql(
+	'postgres', "
+	SET log_statement_max_length TO 12;
+	PREPARE stmt AS SELECT * FROM pg_hba_file_rules WHERE address = \$1;
+	EXECUTE stmt('127.0.0.1');");
+ok( $node->log_contains(
+		qr/prepare: PREPARE stmt...$/m, $log_offset),
+	"Truncate prepared statement query in DETAIL");
+
+note "Truncate prepared statement query in DETAIL (0 length)";
+$log_offset = -s $node->logfile;
+$node->psql(
+	'postgres', "
+	SET log_statement_max_length TO 0;
+	PREPARE stmt AS SELECT * FROM pg_hba_file_rules WHERE address = \$1;
+	EXECUTE stmt('127.0.0.1');");
+ok( $node->log_contains(
+		qr/prepare: ...$/m, $log_offset),
+	"Truncate prepared statement query in DETAIL (0 length)");
 $node->stop;
 done_testing();
-- 
2.25.1

From 7110f93844d22f92ee664c2e5ba2d959ce7180bf Mon Sep 17 00:00:00 2001
From: Jim Jones <[email-masked]>
Date: Wed, 8 Jul 2026 18:31:27 +0200
Subject: [PATCH v1 2/2] Simplify callers of truncate_query_log()

truncate_query_log() already returns NULL when query logging is disabled
or when no truncation is needed. Callers therefore do not need to check
log_statement_max_length before invoking it.

Remove the redundant checks and rely on truncate_query_log() to decide
whether a truncated copy should be returned. This is purely a code
cleanup with no behavioral change.

Author: Jim Jones <[email-masked]>
---
 src/backend/tcop/postgres.c | 30 ++++++------------------------
 1 file changed, 6 insertions(+), 24 deletions(-)

diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index b0947fd7298..61d28728ce1 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -1085,10 +1085,7 @@ exec_simple_query(const char *query_string)
 	/* Log immediately if dictated by log_statement */
 	if (check_log_statement(parsetree_list))
 	{
-		char	   *truncated_stmt = NULL;
-
-		if (log_statement_max_length >= 0)
-			truncated_stmt = truncate_query_log(query_string);
+		char	   *truncated_stmt = truncate_query_log(query_string);
 
 		ereport(LOG,
 				(errmsg("statement: %s",
@@ -1392,10 +1389,7 @@ exec_simple_query(const char *query_string)
 			break;
 		case 2:
 			{
-				char	   *truncated_stmt = NULL;
-
-				if (log_statement_max_length >= 0)
-					truncated_stmt = truncate_query_log(query_string);
+				char	   *truncated_stmt = truncate_query_log(query_string);
 
 				ereport(LOG,
 						(errmsg("duration: %s ms  statement: %s",
@@ -1638,10 +1632,7 @@ exec_parse_message(const char *query_string,	/* string to execute */
 			break;
 		case 2:
 			{
-				char	   *truncated_stmt = NULL;
-
-				if (log_statement_max_length >= 0)
-					truncated_stmt = truncate_query_log(query_string);
+				char	   *truncated_stmt = truncate_query_log(query_string);
 
 				ereport(LOG,
 						(errmsg("duration: %s ms  parse %s: %s",
@@ -2125,10 +2116,7 @@ exec_bind_message(StringInfo input_message)
 			break;
 		case 2:
 			{
-				char	   *truncated_stmt = NULL;
-
-				if (log_statement_max_length >= 0)
-					truncated_stmt = truncate_query_log(psrc->query_string);
+				char	   *truncated_stmt = truncate_query_log(psrc->query_string);
 
 				ereport(LOG,
 						(errmsg("duration: %s ms  bind %s%s%s: %s",
@@ -2282,10 +2270,7 @@ exec_execute_message(const char *portal_name, long max_rows)
 	/* Log immediately if dictated by log_statement */
 	if (check_log_statement(portal->stmts))
 	{
-		char	   *truncated_source = NULL;
-
-		if (log_statement_max_length >= 0)
-			truncated_source = truncate_query_log(sourceText);
+		char	   *truncated_source = truncate_query_log(sourceText);
 
 		ereport(LOG,
 				(errmsg("%s %s%s%s: %s",
@@ -2414,10 +2399,7 @@ exec_execute_message(const char *portal_name, long max_rows)
 			break;
 		case 2:
 			{
-				char	   *truncated_source = NULL;
-
-				if (log_statement_max_length >= 0)
-					truncated_source = truncate_query_log(sourceText);
+				char	   *truncated_source = truncate_query_log(sourceText);
 
 				ereport(LOG,
 						(errmsg("duration: %s ms  %s %s%s%s: %s",
-- 
2.25.1

---

From: Fujii Masao <[email-masked]>
Date: Thu, 9 Jul 2026 13:03:01 +0900

> Attached 0001 addressing the points you made:

Thanks for the patch!

- errdetail("prepare: %s", pstmt->plansource->query_string);
+ char *truncated_stmt = truncate_query_log(pstmt->plansource->query_string);
+
+ errdetail("prepare: %s", truncated_stmt ? truncated_stmt :
pstmt->plansource->query_string);

Shouldn't truncated_stmt be freed with pfree() (when non-NULL)
immediately after errdetail()?

Since an ellipsis is appended, the logged output can exceed the configured
limit by three bytes. Wouldn't it be clearer to say something like
"has its statement text truncated to at most this many bytes"?
That makes it clear the limit applies to the statement text, not the entire
logged message.

Shouldn't the dots be escaped? As written, "..." matches any three characters.
I think it should be "\.\.\.".

    # a 24-byte query should end at the 20th byte ('C').

Since an ellipsis is now appended, isn't it better to replace this
with "a 24-byte query should be clipped at the 20th byte ('C')
and followed by an ellipsis."?

    # With log_statement_max_length = 20, a 24-byte query should end
    # at the 20th byte ('C').

Similarly, isn't it better to replace this with
"With log_statement_max_length = 20, a 24-byte query should be clipped
at the 20th byte ('C'). and followed by an ellipsis."?

> arguably correct, as it indicates that the whole query has been

So, should we update the docs and comments accordingly? For example:

 --------------------------------------------
-        A value of zero causes statements to be logged with an empty body.
+        A value of zero causes statements to be logged as only an ellipsis.
         <literal>-1</literal> (the default) logs statements in full.
         If this value is specified without units, it is taken as bytes.

{ name => 'log_statement_max_length', type => 'int', context =>
'PGC_SUSET', group => 'LOGGING_WHAT',
-  short_desc => 'Sets the maximum length in bytes of logged statements.',
-  long_desc => '-1 means log statement in full; 0 means log an empty
statement body.',
+  short_desc => 'Sets the maximum length in bytes of logged statement text.',
+  long_desc => '-1 means log statement in full; 0 means log only an ellipsis.',

- #log_statement_max_length = -1          # max bytes of logged statements;
+ #log_statement_max_length = -1          # max bytes of logged statement text;
                                         # -1 means log statement in full,
-                                        # 0 means log an empty body
+                                        # 0 means log only an ellipsis

-# Verify 0 logs an empty statement body.
+# Verify 0 logs only an ellipsis.
 note "Zero length truncation";

ok($node->log_contains(qr/statement: \.\.\.\s*$/m, $log_offset),
-       "0 logs statement body with ellipsis");
+       "0 logs only an ellipsis");
 --------------------------------------------

> 0002 attached does this.

+1 to this patch!

> BTW, should I open a new CF entry for this?

Yes, I think that's a good idea so these follow-up patches don't get forgotten.

Regards,

-- 
Fujii Masao

---

From: Jim Jones <[email-masked]>
Date: Thu, 9 Jul 2026 09:41:57 +0200

Thanks for the thorough review!

> immediately after errdetail()?

Yes, I missed that one.
Fixed.

> That makes it clear the limit applies to the statement text, not the entire

Agreed.
I added your suggestion.

> I think it should be "\.\.\.".

At first I also thought so, as in this context ... could mean any three
arbitrary characters, but the tests results showed a different picture,
so I thought it was not necessary. Anyway, I escaped them now in v2.

> So, should we update the docs and comments accordingly? For example:

Comments and docs updated.

> Yes, I think that's a good idea so these follow-up patches don't get forgotten.

Done: https://commitfest.postgresql.org/patch/6998/

Best, Jim
From 39072fa5bff8e23d4c56604d45635f835a0c78ad Mon Sep 17 00:00:00 2001
From: Jim Jones <[email-masked]>
Date: Thu, 9 Jul 2026 09:25:32 +0200
Subject: [PATCH v2 1/2] Improve log_statement_max_length truncation

Author: Jim Jones <[email-masked]>
Co-authored-by: Fujii Masao <[email-masked]>
---
 doc/src/sgml/config.sgml                      |  6 ++-
 src/backend/tcop/postgres.c                   | 20 +++++---
 src/backend/utils/misc/postgresql.conf.sample |  4 +-
 .../t/014_log_statement_max_length.pl         | 46 ++++++++++++++-----
 4 files changed, 54 insertions(+), 22 deletions(-)

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index c67130c620e..0848c18d329 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -8539,8 +8539,10 @@ log_line_prefix = '%m [%p] %q%u@%d/%a '
         <xref linkend="guc-log-min-duration-statement"/>,
         <xref linkend="guc-log-min-duration-sample"/>, or
         <xref linkend="guc-log-transaction-sample-rate"/>
-        is truncated to at most this many bytes.
-        A value of zero causes statements to be logged with an empty body.
+        has its statement text truncated to at most this many bytes.
+        When a statement is truncated, an ellipsis (<literal>...</literal>)
+        is appended to indicate that truncation has occurred.
+        A value of zero causes statements to be logged as only an ellipsis.
         <literal>-1</literal> (the default) logs statements in full.
         If this value is specified without units, it is taken as bytes.
         This setting does not affect statements logged because of
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index ce18df820cd..019e4ab42fe 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2553,8 +2553,9 @@ check_log_duration(char *msec_str, bool was_logged)
  * truncate_query_log
  *		Truncate query string if needed for logging
  *
- * Returns a palloc'd truncated copy if truncation is needed,
- * or NULL if no truncation is required.
+ * Returns a palloc'd copy of the query truncated for logging, with an
+ * ellipsis appended if truncation occurs, or NULL if no truncation is
+ * required.
  */
 static char *
 truncate_query_log(const char *query)
@@ -2567,7 +2568,7 @@ truncate_query_log(const char *query)
 	if (!query || log_statement_max_length < 0)
 		return NULL;

 	/*

 	return truncated_query;
 }
@@ -2608,7 +2610,13 @@ errdetail_execute(List *raw_parsetree_list)
 			pstmt = FetchPreparedStatement(stmt->name, false);
 			if (pstmt)
 			{
-				errdetail("prepare: %s", pstmt->plansource->query_string);
+				char *truncated_stmt = truncate_query_log(pstmt->plansource->query_string);
+
+				errdetail("prepare: %s", truncated_stmt ? truncated_stmt : pstmt->plansource->query_string);
+
+				if (truncated_stmt != NULL)
+					pfree(truncated_stmt);
+
 				return 0;
 			}
 		}
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index 47e1221f3c3..7958653077b 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -676,9 +676,9 @@
                                         # bind-parameter values to N bytes;
                                         # -1 means print in full, 0 disables
 #log_statement = 'none'                 # none, ddl, mod, all
-#log_statement_max_length = -1          # max bytes of logged statements;
+#log_statement_max_length = -1          # max bytes of logged statement text;
                                         # -1 means log statement in full,
-                                        # 0 means log an empty body
+                                        # 0 means log only an ellipsis
 #log_replication_commands = off
 #log_temp_files = -1                    # log temporary files equal or larger
                                         # than the specified size in kilobytes;
diff --git a/src/test/modules/test_misc/t/014_log_statement_max_length.pl b/src/test/modules/test_misc/t/014_log_statement_max_length.pl
index b1ce6068f5d..b879566ef54 100644
--- a/src/test/modules/test_misc/t/014_log_statement_max_length.pl
+++ b/src/test/modules/test_misc/t/014_log_statement_max_length.pl
@@ -15,14 +15,15 @@ $node->init();
 $node->start;
 
 # Verify ASCII truncation. With log_statement_max_length = 20,
-# a 24-byte query should end at the 20th byte ('C').
+# a 24-byte query should be clipped at the 20th byte ('C') and
+# followed by an ellipsis.
 note "ASCII truncation via log_statement";
 my $log_offset = -s $node->logfile;
 $node->psql(
 	'postgres', "
 	SET log_statement_max_length TO 20;
 	SELECT '123456789ABCDEF';");
-ok($node->log_contains(qr/statement: SELECT '123456789ABC$/m, $log_offset),
+ok($node->log_contains(qr/statement: SELECT '123456789ABC\.\.\.$/m, $log_offset),
 	"ASCII query truncated at 20 bytes");
 
 # Verify -1 logs statement in full (closing quote must be present).
@@ -51,23 +52,23 @@ SKIP:
 		SET client_encoding TO 'UTF8';
 		SET log_statement_max_length TO 11;
 		$mbquery");
-	ok($node->log_contains(qr/statement: SELECT 'AA$/m, $log_offset),
+	ok($node->log_contains(qr/statement: SELECT 'AA\.\.\.$/m, $log_offset),
 		"multibyte truncation at character boundary");
 }
 
-# Verify 0 logs an empty statement body.
+# Verify 0 logs only an ellipsis.
 note "Zero length truncation";
 $log_offset = -s $node->logfile;
 $node->psql(
 	'postgres', "
 	SET log_statement_max_length TO 0;
 	SELECT '123456789ABCDEF';");
-ok($node->log_contains(qr/statement:\s*$/m, $log_offset),
-	"0 logs an empty statement body");
+ok($node->log_contains(qr/statement: \.\.\.\s*$/m, $log_offset),
+	"0 logs statement body with only an ellipsis");
 
 # Verify truncation via the extended query protocol (execute message).
-# With log_statement_max_length = 20, a 24-byte query should end
-# at the 20th byte ('C').
+# With log_statement_max_length = 20, a 24-byte query should be clipped
+# at the 20th byte ('C') and followed by an ellipsis.
 note "Extended query protocol (execute) truncation";
 $log_offset = -s $node->logfile;
 $node->psql(
@@ -75,7 +76,7 @@ $node->psql(
 	SET log_statement_max_length TO 20;
 	SELECT '123456789ABCDEF' \\bind \\g");
 ok( $node->log_contains(
-		qr/execute <unnamed>: SELECT '123456789ABC$/m, $log_offset),
+		qr/execute <unnamed>: SELECT '123456789ABC\.\.\.$/m, $log_offset),
 	"extended protocol execute truncated at 20 bytes");
 
 # Verify extended protocol also respects -1 (no truncation; closing quote
@@ -102,14 +103,35 @@ $node->psql(
 	SET log_statement_max_length TO 20;
 	SELECT '123456789ABCDEF' \\bind \\g");
 ok( $node->log_contains(
-		qr/parse <unnamed>: SELECT '123456789ABC$/m, $log_offset),
+		qr/parse <unnamed>: SELECT '123456789ABC\.\.\.$/m, $log_offset),
 	"parse duration entry truncated");
 ok( $node->log_contains(
-		qr/bind <unnamed>: SELECT '123456789ABC$/m, $log_offset),
+		qr/bind <unnamed>: SELECT '123456789ABC\.\.\.$/m, $log_offset),
 	"bind duration entry truncated");
 ok( $node->log_contains(
-		qr/execute <unnamed>: SELECT '123456789ABC$/m, $log_offset),
+		qr/execute <unnamed>: SELECT '123456789ABC\.\.\.$/m, $log_offset),
 	"execute duration entry truncated");
 
+note "Truncate prepared statement query in DETAIL";
+$log_offset = -s $node->logfile;
+$node->psql(
+	'postgres', "
+	SET log_statement_max_length TO 12;
+	PREPARE stmt AS SELECT * FROM pg_hba_file_rules WHERE address = \$1;
+	EXECUTE stmt('127.0.0.1');");
+ok( $node->log_contains(
+		qr/prepare: PREPARE stmt\.\.\.$/m, $log_offset),
+	"Truncate prepared statement query in DETAIL");
+
+note "Truncate prepared statement query in DETAIL (0 length)";
+$log_offset = -s $node->logfile;
+$node->psql(
+	'postgres', "
+	SET log_statement_max_length TO 0;
+	PREPARE stmt AS SELECT * FROM pg_hba_file_rules WHERE address = \$1;
+	EXECUTE stmt('127.0.0.1');");
+ok( $node->log_contains(
+		qr/prepare: \.\.\.$/m, $log_offset),
+	"0 logs the prepared statement body with only an ellipsis");
 $node->stop;
 done_testing();
-- 
2.55.0

From 4c1fb8acd321dea4830b96d1518b1ffd4474b164 Mon Sep 17 00:00:00 2001
From: Jim Jones <[email-masked]>
Date: Wed, 8 Jul 2026 18:31:27 +0200
Subject: [PATCH v2 2/2] Simplify callers of truncate_query_log()

index 019e4ab42fe..b966cb242b7 100644
 
 		ereport(LOG,
 				(errmsg("statement: %s",
 
 				ereport(LOG,
 
 				ereport(LOG,
 
 				ereport(LOG,
 
 		ereport(LOG,
 				(errmsg("%s %s%s%s: %s",
 
 				ereport(LOG,
2.55.0