FORMAT / TO_CHAR(字符串格式化)
格式化(Formatting)是把值「渲染」成人类可读文本的最后一公里:金额要带千位分隔符与货币符号、日期要按业务口径输出、日志要按固定模板拼装。PostgreSQL 提供两条路线:format() 处理模板占位符,to_char() 处理模式串(数字与日期各有自己的模式语言)。二者用途不同却常被混用,本节讲清什么时候该用哪个,以及数字模式下那些反直觉的规则。
前置知识
阅读本节前,建议先了解:SPLIT_PART / STRING_AGG
基础概念
| 概念 | 术语 | 说明 |
|---|---|---|
| 函数式格式化 | format() | %s / %I / %L 占位符,类似 printf |
| 模式串 | Template / Pattern | to_char 的数字 / 日期模式语言 |
| 填充模式 | Fill Mode | 9(可省)、0(补零)、FM(去空格) |
| 货币符号 | Currency Symbol | L 按会话 lc_monetary 输出 |
| 符号位置 | Sign Position | S 前置、MI 后置、PR 尖括号、SG 带正号 |
| 罗马数字 | Roman Numerals | RN / rn 输出罗马数字 |
| 千位分隔 | Group Separator | , 或 G,受 lc_numeric 影响 |
| 模板注入 | Template Injection | %I / %L 防止拼接 SQL 被注入 |
两个函数的分工:
| 函数 | 输入 | 参数风格 | 主要用途 |
|---|---|---|---|
format(fmt, ...) | text 模板 + 任意值 | %s %I %L 占位符 | 拼提示信息、生成 SQL、日志模板 |
to_char(value, fmt) | numeric / date / timestamp / interval | 模式串 | 金额、日期、时长的业务展示 |
to_number(text, fmt) | text + 模式串 | 模式串 | to_char 的逆操作,解析带格式数字 |
to_date(text, fmt) | text + 模式串 | 模式串 | 解析非标准日期串 |
选择流程:
你要输出什么?
├─ 一段带变量的文本(消息 / SQL / 日志)
│ └─ format()
│ ├─ 值直接塞进去 → %s
│ ├─ 塞的是标识符(表名/列名)→ %I(会自动加双引号)
│ └─ 塞的是字面量(用户输入) → %L(会自动加单引号并转义)
├─ 数字 → 带千分位 / 补零 / 货币符号
│ └─ to_char(numeric, '模式')
├─ 日期时间 → 指定格式
│ └─ to_char(timestamp, '模式')
└─ 把格式化文本解析回数值
├─ to_number(text, '模式')
└─ to_date(text, '模式')一句话记忆:format 管「模板拼装」,to_char 管「数值与日期的显示形态」。
语法与用法
-- 1) 模板式格式化
format(formatstr text [, formatarg any]) → text
-- 2) 数字 / 日期 → 文本
to_char(numeric, text) → text
to_char(timestamp, text) → text
to_char(timestamp with time zone, text) → text
to_char(interval, text) → text
-- 3) 文本 → 数值 / 日期
to_number(text, text) → numeric
to_date(text, text) → date
-- 4) 相关的引号工具
quote_ident(text) → text -- 需要时加双引号,安全引用标识符
quote_literal(text) → text -- 加单引号并转义内部单引号
quote_nullable(text)→ text -- 同上,但 NULL 返回字面 'NULL'format() 的三种占位符:
| 占位符 | 作用 | 示例 | 结果 |
|---|---|---|---|
%s | 直接转文本插入 | format('id=%s', 42) | id=42 |
%I | 作为标识符,必要时加双引号 | format('%I', 'user name') | "user name" |
%L | 作为字面量,加单引号并转义 | format('%L', O''Neil) | 'O''Neil' |
%% | 输出一个字面 % | format('100%%') | 100% |
%n$s | 指定第 n 个参数(PostgreSQL 9.1+,与 format() 同时引入) | format('%2$s %1$s', 'a', 'b') | b a |
SELECT
format('user %s has %s orders', 'alice', 3) AS plain,
format('table: %I', 'orders') AS ident_plain,
format('table: %I', 'order items') AS ident_quoted,
format('name = %L', 'O''Neil') AS literal_escape,
format('progress: 100%%') AS percent,
format('a %s c', NULL) AS null_arg; plain | ident_plain | ident_quoted | literal_escape | percent | null_arg
------------------------------+-----------------+---------------------+----------------------+---------------+----------
user alice has 3 orders | table: orders | table: "order items"| name = 'O''Neil' | progress: 100%| a c注意最后一行:%s 遇到 NULL 输出空串(不是 NULL,也不是字面 NULL),这是与 concat 一致的「跳过 NULL」语义。
最小可运行示例 to_char:
SELECT
to_char(1234567.891, '999,999,999.99') AS grouped,
to_char(1234567.891, 'FM999,999,999.99') AS fm_trimmed,
to_char(1234567.891, '$999,999,999.99') AS dollar,
to_char(1234567.891, '000,000,000.00') AS zero_padded,
to_char(-123.45, '(999.99)') AS paren_negative,
to_char(0.5, 'FM990.00') AS fm_leading_zero; grouped | fm_trimmed | dollar | zero_padded | paren_negative | fm_leading_zero
-----------------------+------------------+--------------------+------------------+----------------+-----------------
1,234,567.89 | 1,234,567.89 | $1,234,567.89 | 000,001,234,568 | (123.45) | 0.50准备演示数据(示例库 demo):
CREATE TABLE payments (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no varchar(32) NOT NULL,
amount numeric(12,2) NOT NULL,
fee numeric(6,4),
paid_at timestamptz NOT NULL,
channel varchar(16) NOT NULL
);
INSERT INTO payments (order_no, amount, fee, paid_at, channel) VALUES
('SO-2026-0001', 577.00, 0.0060, '2026-01-05 09:12:33+08', 'alipay'),
('SO-2026-0002', 261.00, 0.0060, '2026-01-05 14:03:07+08', 'wechat'),
('SO-2026-0003', 293.50, 0.0055, '2026-02-11 20:45:59+08', 'card'),
('SO-2026-0004', 12888.00,0.0038, '2026-03-01 00:00:01+08', 'card'),
('SO-2026-0005', 0.99, 0.0100, '2026-03-15 23:59:59+08', 'wechat');INSERT 0 5详细说明
数字模式:9 与 0 的区别
这是 to_char 数字模式里最容易混淆的一对:
| 模式字符 | 含义 | 无该位时 | 示例(输入 5) |
|---|---|---|---|
9 | 可选的数字位 | 输出空格 | to_char(5,'99') → ' 5' |
0 | 必填的数字位,补零 | 输出 0 | to_char(5,'00') → '05' |
FM | 前缀修饰符,去首尾空格与补零 | — | to_char(5,'FM99') → '5' |
SELECT
'[' || to_char(5, '9999') || ']' AS nine_mode,
'[' || to_char(5, '0000') || ']' AS zero_mode,
'[' || to_char(5, 'FM9999') || ']' AS fm_nine,
'[' || to_char(5, 'FM0000') || ']' AS fm_zero,
'[' || to_char(5, '999990') || ']' AS mixed,
'[' || to_char(5, 'FM999990')|| ']' AS fm_mixed; nine_mode | zero_mode | fm_nine | fm_zero | mixed | fm_mixed
---------------+-----------+---------+---------+----------+----------
[ 5] | [0005] | [5] | [0005] | [ 5] | [5]关键结论:9 不补零但补空格;0 补零。想要「不留空格的整数」必须用 FM 前缀。
FM 只作用于最外层且必须写在模式开头。多个 FM 无效。
符号与括号:S、MI、PL、PR、SG
| 模式 | 含义 | to_char(123,'模式') | to_char(-123,'模式') |
|---|---|---|---|
S999 | 前置符号(+/-) | +123 | -123 |
999S | 后置符号 | 123+ | 123- |
999MI | 负号后置,正数留空格 | 123 | 123- |
999PL | 正号后置,负数留空格 | 123+ | 123 |
999PR | 负数用尖括号包裹 | 123 | <123> |
S9999SG | 前置符号 + 正号(SG 用于正数) | +123 | -123 |
SELECT
to_char(123, 'S999') AS s_pos, to_char(-123, 'S999') AS s_neg,
to_char(123, '999S') AS s_tail_pos, to_char(-123, '999S') AS s_tail_neg,
to_char(123, '999MI') AS mi_pos, to_char(-123, '999MI') AS mi_neg,
to_char(123, '999PR') AS pr_pos, to_char(-123, '999PR') AS pr_neg; s_pos | s_neg | s_tail_pos | s_tail_neg | mi_pos | mi_neg | pr_pos | pr_neg
-------+-------+------------+------------+--------+--------+--------+--------
+123 | -123 | 123+ | 123- | 123 | 123- | 123 | <123>to_char 的数字输出常带前导空格
因为 9 用空格填充,to_char(123,'999') 得到 ' 123'(注意前导空格)。把它 || 拼进文本时会出现「莫名多一个空格」的 bug:
SELECT '¥' || to_char(123, '999.00'); -- ¥ 123.00(中间有空格,不推荐)
SELECT '¥' || to_char(123, 'FM999.00'); -- ¥123.00(正确)货币、千分位与小数点
| 模式字符 | 含义 | 说明 |
|---|---|---|
, | 千位分隔符(模式里的定位符号) | 实际输出字符受 lc_numeric 影响 |
G | 同上,位置由 locale 决定 | 与 , 等价但更「本地化」 |
D | 小数点,位置由 locale 决定 | 与 . 等价但更「本地化」 |
L | 货币符号 | 按 lc_monetary 输出 |
$ | 固定美元符号 | 与 locale 无关 |
SELECT
to_char(1234567.891, '999,999,999.99') AS comma,
to_char(1234567.891, '999G999G999D99') AS locale_g,
to_char(1234567.891, 'L999,999,999.99') AS local_currency,
to_char(1234567.891, '$999,999,999.99') AS dollar,
current_setting('lc_numeric') AS lc_numeric,
current_setting('lc_monetary') AS lc_monetary; comma | locale_g | local_currency | dollar | lc_numeric | lc_monetary
--------------------+------------------+------------------+--------------------+------------+-----------------------
1,234,567.89 | 1,234,567.89 | ¥1,234,567.89 | $1,234,567.89 | C.UTF-8 | C.UTF-8L 的输出依赖 lc_monetary。在 C / C.UTF-8 下通常输出 ¥ 或 $(视集群初始化时的 locale 而定);要让 L 稳定输出人民币符号,应显式设置会话或直接写死 '¥'。
罗马数字与特殊格式
SELECT
to_char(2026, 'RN') AS roman_upper, -- MMXXVI
to_char(2026, 'rn') AS roman_lower, -- mmxxvi
to_char(4, 'FM9RN') AS with_roman, -- 4IV
to_char(1234567, '9.99EEEE') AS sci_notation, -- 1.23e+06
to_char(1234567, 'FM9.99EEEE') AS sci_fm; -- 1.23e+06 roman_upper | roman_lower | with_roman | sci_notation | sci_fm
-------------+-------------+------------+--------------+---------
MMXXVI | mmxxvi | 4IV | 1.23e+06 | 1.23e+06RN/rn只对 1~3999 的整数有意义。EEEE切换到科学计数法,仅用于numeric/float的展示,不建议用于金额。
to_char 的日期与时间模式
日期模式与数字模式是两套完全不同的语言(同一个字符有不同含义,例如 D 在数字里是小数点、在日期里是「星期几」):
| 模式 | 含义 | 示例输出 |
|---|---|---|
YYYY | 4 位年份 | 2026 |
YY | 2 位年份 | 26 |
MM | 月份(01~12) | 03 |
MON | 月名缩写 | Mar |
Month | 月名全称 | March |
DD | 月内日(01~31) | 15 |
DDD | 年内日(001~366) | 074 |
DAY | 星期全称 | SUNDAY |
DY | 星期缩写 | SUN |
HH24 | 24 小时制(00~23) | 23 |
HH12 / HH | 12 小时制 | 11 |
MI | 分钟 | 59 |
SS | 秒 | 59 |
MS | 毫秒(000~999) | 123 |
US | 微秒(000000~999999) | 123456 |
TZ | 时区缩写 | CST |
OF | 时区偏移 | +08 |
Q | 季度 | 1 |
IW | ISO 周数 | 11 |
IW/W | 月内周数 | 3 |
SELECT
to_char(paid_at, 'YYYY-MM-DD') AS date_iso,
to_char(paid_at, 'YYYY"年"MM"月"DD"日"') AS date_cn,
to_char(paid_at, 'Dy HH24:MI:SS') AS datetime_short,
to_char(paid_at, 'Day, DD Mon YYYY HH12:MI AM') AS pretty,
to_char(paid_at, 'Q') AS quarter,
to_char(paid_at, 'DDD') AS day_of_year,
to_char(paid_at, 'IW') AS iso_week,
to_char(paid_at, 'TZ OF') AS tz_info
FROM payments
ORDER BY id
LIMIT 2; date_iso | date_cn | datetime_short | pretty | quarter | day_of_year | iso_week | tz_info
------------+--------------+----------------+--------------------------+---------+-------------+----------+----------
2026-01-05 | 2026年01月05日| Mon 09:12:33 | Monday , 05 Jan 2026 09:12 AM | 1 | 005 | 02 | CST +08
2026-01-05 | 2026年01月05日| Mon 14:03:07 | Monday , 05 Jan 2026 02:03 PM | 1 | 005 | 02 | CST +08中文模式里的「年月日」需要用双引号包起来
to_char(now(), 'YYYY年MM月DD日') 会解析失败或产生意外输出,因为 年 月 日 可能与模式字符冲突。正确写法是用双引号把它们标记为字面文本:
SELECT to_char(now(), 'YYYY"年"MM"月"DD"日"'); -- 2026年09月24日日期模式还有 FM 前缀,用于去掉 Month / DAY 等定长模式补的尾随空格:
SELECT
'[' || to_char(DATE '2026-03-15', 'Day') || ']' AS padded, -- [Sunday ]
'[' || to_char(DATE '2026-03-15', 'FMDay') || ']' AS trimmed, -- [Sunday]
'[' || to_char(DATE '2026-03-15', 'Month') || ']' AS month_pad,
'[' || to_char(DATE '2026-03-15', 'FMMonth')|| ']' AS month_fm; padded | trimmed | month_pad | month_fm
-----------------+---------+-------------+----------
[Sunday ] | [Sunday]| [March ] | [March]format() 的模板注入防护
format() 最有价值的用途不是「拼日志」,而是安全地生成动态 SQL。%I 会正确处理标识符,%L 会正确转义单引号。
拼接构造 SQL 等于开门揖盗
只要有一个来自用户输入的标识符或字面量是自己拼进 SQL 文本的,整个语句就不可信:
- 标识符(表名、列名)必须过
%I或quote_ident(),否则order items' WHERE 1=1 --之类的输入能改写语句结构; - 字面量(字符串值)必须过
%L、quote_literal()或参数化查询,否则一个单引号就能闭合字符串并追加任意 SQL; - 更根本的解法是参数化:能用
EXECUTE ... USING或客户端预编译语句时,优先用参数而不是format()拼值,%L只是在无法参数化位置(标识符)或必须生成文本时的兜底。
-- 危险:字符串拼接,遇到带引号的值直接注入
SELECT 'SELECT * FROM ' || 'orders' || ' WHERE customer = ''' || 'O''Neil' || '''';
-- 结果:SELECT * FROM orders WHERE customer = 'O'Neil' ← 语法错误,可被注入
-- 安全:用 %I 与 %L
SELECT format('SELECT * FROM %I WHERE customer = %L', 'orders', 'O''Neil');
-- 结果:SELECT * FROM orders WHERE customer = 'O''Neil' ?column?
-----------------------------------------------------
SELECT * FROM orders WHERE customer = 'O''Neil'| 工具 | 何时自动加引号 | 空值处理 |
|---|---|---|
%I | 标识符含大写、空格、保留字时 | NULL → 空串 |
%L | 总是(单引号) | NULL → 字面 NULL(无引号) |
%s | 从不 | NULL → 空串 |
quote_ident | 同上 | NULL → NULL |
quote_literal | 总是 | NULL → NULL |
quote_nullable | 总是 | NULL → 字符串 'NULL' |
SELECT
quote_ident('orders') AS qi_plain,
quote_ident('Order Items') AS qi_spaced,
quote_literal('O''Neil') AS ql_escape,
quote_nullable(NULL) AS qn_null,
quote_literal(NULL) AS ql_null,
format('%L', NULL) AS fmt_null_l; qi_plain | qi_spaced | ql_escape | qn_null | ql_null | fmt_null_l
----------+------------------+----------------+---------+---------+------------
orders | "Order Items" | 'O''Neil' | 'NULL' | | NULL%L 遇到 NULL 时输出不带引号的 NULL(对 SQL 而言这正是空值字面量),而 quote_nullable(NULL) 输出带引号的字符串 'NULL'——两者用途不同,插入 SQL 用 %L 更合适。
to_number:to_char 的逆操作
SELECT
to_number('1,234,567.89', '999,999,999.99') AS parse_grouped, -- 1234567.89
to_number('$1,234.50', '$999,999.99') AS parse_currency, -- 1234.50
to_number(' 42 ', '999') AS parse_spaces, -- 42
to_number('12.5%', '99.9%') AS parse_percent, -- 12.5(% 只是标记)
to_number('abc', '999'); -- 0(解析失败返回 0!) parse_grouped | parse_currency | parse_spaces | parse_percent | to_number
---------------+----------------+--------------+---------------+-----------
1234567.89 | 1234.50 | 42 | 12.5 | 0to_number 解析失败返回 0,不报错也不返回 NULL
这是数据清洗时最危险的行为:脏数据会被静默变成 0,进入金额字段后极难排查。务必先用正则或 CASE 校验:
SELECT
raw,
CASE WHEN raw ~ '^-?\d+(\.\d+)?$'
THEN to_number(raw, '999999999.99')
ELSE NULL
END AS safe_amount
FROM (VALUES ('12.50'), ('abc'), (''), ('1,234.00')) AS t(raw); raw | safe_amount
----------+-------------
12.50 | 12.50
abc |
|
1,234.00 |format() 与 to_char() 的性能
两者开销都远小于正则,但在大批量输出时仍有差异:
\timing on
SELECT count(format('%s-%s', id, order_no)) FROM payments;
SELECT count(to_char(amount, 'FM999,999.99')) FROM payments;
SELECT count('SO-' || lpad(id::text, 4, '0')) FROM payments; count
-------
5
Time: 0.412 ms
count
-------
5
Time: 0.531 ms
count
-------
5
Time: 0.207 ms经验规律:纯拼接(||)最快,format 次之,to_char 稍慢(要解析模式串)。但差异在毫秒级,除极端热路径外不必为此牺牲可读性。
to_char 是 STABLE(因为依赖 lc_numeric / lc_monetary 等会话设置),不能用于表达式索引;format 在参数为常量时也是 STABLE。想在索引里固化格式化结果,只能用 || 与 lpad 这类 IMMUTABLE 操作。
| 函数 | 易变性 | 可否建表达式索引 |
|---|---|---|
|| 拼接 | IMMUTABLE | 可以 |
lpad / rpad | IMMUTABLE | 可以 |
format | STABLE | 不可以 |
to_char(数字/日期) | STABLE | 不可以 |
实战示例
场景一:订单对账单(金额 + 日期 + 序号)
SELECT
format('%s / %s', order_no, channel) AS tag,
to_char(amount, 'FM999,999,990.00') AS amount_str,
'¥' || to_char(amount, 'FM999,999,990.00') AS amount_cny,
to_char(fee * 100, 'FM990.00') || '%' AS fee_pct,
to_char(paid_at, 'YYYY-MM-DD HH24:MI') AS paid_str,
to_char(paid_at, 'FMDy') AS weekday
FROM payments
ORDER BY paid_at; tag | amount_str | amount_cny | fee_pct | paid_str | weekday
----------------------------+---------------+---------------+---------+------------------+---------
SO-2026-0001 / alipay | 577.00 | ¥577.00 | 0.60% | 2026-01-05 09:12 | Mon
SO-2026-0002 / wechat | 261.00 | ¥261.00 | 0.60% | 2026-01-05 14:03 | Mon
SO-2026-0003 / card | 293.50 | ¥293.50 | 0.55% | 2026-02-11 20:45 | Wed
SO-2026-0004 / card | 12,888.00 | ¥12,888.00 | 0.38% | 2026-03-01 00:00 | Sun
SO-2026-0005 / wechat | 0.99 | ¥0.99 | 1.00% | 2026-03-15 23:59 | Sun注意 FM999,999,990.00 里的 0:它保证整数部分至少输出到个位,避免 0.99 被渲染成 .99。这是金额格式化里最容易被忽略的细节。
场景二:按月汇总的对账报表
SELECT
to_char(paid_at, 'YYYY-MM') AS month,
to_char(sum(amount), 'FM9,999,999,990.00') AS total,
count(*) AS orders,
to_char(avg(amount), 'FM990.00') AS avg_amount
FROM payments
GROUP BY 1
ORDER BY 1; month | total | orders | avg_amount
----------+----------------+--------+------------
2026-01 | 838.00 | 2 | 419.00
2026-02 | 293.50 | 1 | 293.50
2026-03 | 12,888.99 | 2 | 6444.50to_char(paid_at,'YYYY-MM') 也可以直接写 GROUP BY 1,因为 to_char 的结果是文本,按月分组时按字典序排也正确。
场景三:生成可审计的动态 SQL
-- 用 format 拼装带标识符与字面量的动态 SQL,避免注入
DO $$
DECLARE
v_table text := 'orders';
v_cust text := 'O''Neil';
v_sql text;
BEGIN
v_sql := format(
'SELECT count(*) FROM %I WHERE customer = %L',
v_table, v_cust
);
RAISE NOTICE 'SQL: %', v_sql;
END $$;NOTICE: SQL: SELECT count(*) FROM orders WHERE customer = 'O''Neil'
DO把这段逻辑放进函数里,配合 EXECUTE ... USING 更安全:
CREATE OR REPLACE FUNCTION count_orders_by_customer(p_customer text)
RETURNS bigint
LANGUAGE plpgsql
AS $$
DECLARE
v_count bigint;
BEGIN
-- 标识符用 %I 校验,值用参数绑定(USING)而非拼进字符串
EXECUTE format('SELECT count(*) FROM %I WHERE customer = $1', 'orders')
INTO v_count
USING p_customer;
RETURN v_count;
END $$;
SELECT count_orders_by_customer('alice') AS alice_orders,
count_orders_by_customer('O''Neil') AS oneil_orders; alice_orders | oneil_orders
--------------+--------------
1 | 0场景四:to_number 与 to_date 解析非标准输入
SELECT
to_number('1.234,56', '9G999D99') AS eu_format,
to_date('20260315', 'YYYYMMDD') AS compact_date,
to_date('15/03/2026', 'DD/MM/YYYY') AS slash_date,
to_date('Mar 15, 2026', 'Mon DD, YYYY') AS named_month; eu_format | compact_date | slash_date | named_month
-----------+--------------+------------+-------------
1234.56 | 2026-03-15 | 2026-03-15 | 2026-03-15上例的
9G999D99在.为小数点、,为千分位的 locale 下解析1.234,56时会得到1234.56(G匹配千分位、D匹配小数点)。这依赖 locale 设置,跨环境时结果可能不同,生产代码里应显式验证 locale,或改用replace预处理分隔符再解析。
注意事项
format的%s遇NULL输出空串,无法与空串区分:sqlSELECT format('a%sb', NULL); -- 'ab' SELECT format('a%sb', ''); -- 'ab'%L遇NULL输出不带引号的NULL,而%s输出空串:sqlSELECT format('%L', NULL); -- NULL SELECT format('%s', NULL); -- (空串)%必须写成%%,否则会被当成占位符:sqlSELECT format('100% done'); -- 错误!报错 too few arguments SELECT format('100%% done'); -- 正确:100% doneformat的参数个数不足会报错,多余参数会被忽略:sqlSELECT format('%s %s', 'a'); -- 错误!too few arguments for format() SELECT format('%s', 'a', 'b'); -- 'a'(多余参数静默忽略)to_char的数字模式里9会补空格,拼接时会出现多余空格:sqlSELECT '¥' || to_char(123, '999.00'); -- 不推荐:'¥ 123.00' SELECT '¥' || to_char(123, 'FM999.00'); -- 正确:'¥123.00'不写
0会导致小数点前缺位:sqlSELECT to_char(0.99, 'FM99.99'); -- 错误!'.99' SELECT to_char(0.99, 'FM990.99'); -- 正确:'0.99'FM必须写在模式最前面,写在中间无效:sqlSELECT to_char(5, '999FM9'); -- 无效写法,FM 不生效 SELECT to_char(5, 'FM9999'); -- 正确:'5'to_char的日期模式里普通汉字要加双引号,否则可能被误解析:sql-- 正确写法 SELECT to_char(now(), 'YYYY"年"MM"月"DD"日"');to_char的Day/Month等定长模式会补空格,直接比较会失败:sqlSELECT to_char(DATE '2026-03-15', 'Day') = 'Sunday'; -- false(右侧少了空格) SELECT to_char(DATE '2026-03-15', 'FMDay') = 'Sunday'; -- trueto_number解析失败返回0,静默吞掉脏数据:sqlSELECT to_number('abc', '999'); -- 0(不是 NULL,也不是错误)to_char是STABLE,不能建表达式索引。需要在索引里用格式化结果时,改用lpad/||等IMMUTABLE操作。L货币符号依赖lc_monetary,跨环境结果可能不同。要稳定输出人民币符号应直接写'¥'字面量。数字模式的长度决定输出宽度,模式位数不够会输出
#:sqlSELECT to_char(12345, '999'); -- '###'(位数不足用 # 填充!) SELECT to_char(12345, '99999'); -- ' 12345'%I加引号的规则是「必要时」,小写且非保留字的标识符不加引号。若要强制加引号,需自行拼接:sqlSELECT format('%I', 'orders'); -- orders(无引号) SELECT format('%I', 'Order'); -- "Order"(有大写,加引号)
最佳实践
金额、日期等业务展示统一用
to_char封装,不要在应用层各写各的格式串。用视图把格式化结果固化下来:sqlCREATE OR REPLACE VIEW v_payment_display AS SELECT id, order_no, '¥' || to_char(amount, 'FM999,999,990.00') AS amount_display, to_char(paid_at, 'YYYY-MM-DD HH24:MI') AS paid_display FROM payments;金额格式化模板固定为
FM999,999,990.00(或按业务位宽调整),其中的0保证小数点前至少一位,FM去掉前导空格。把这条写进团队规范。动态 SQL 一律用
format+%I/%L,绝不用||拼接用户输入。值优先用EXECUTE ... USING绑定参数,%L作为次选。to_number前必须先校验格式。用正则~ '^-?\d+(\.\d+)?$'过滤,或改用NULLIF包一层,避免0静默污染。to_char的输出不要参与后续计算。它是文本,'1,234.56'无法直接做算术。计算用原始numeric列,只在最终展示时格式化。日期格式化优先用 ISO 风格
YYYY-MM-DD。它是可排序的、无歧义的、跨 locale 稳定的;只有面向终端用户展示时才用本地化格式。需要手动加引号的标识符用
%I,需要NULL字面量的用quote_nullable,分清两者对NULL的处理差异。format的模板字符串写成长常量并配注释,说明每个占位符的预期类型:sql-- %s = order_no, %s = channel, %L = customer format('%s/%s customer=%L', order_no, channel, customer)避免在
WHERE条件里对列调用to_char。这是索引杀手:sql-- 不推荐!无法用 paid_at 上的索引 SELECT * FROM payments WHERE to_char(paid_at, 'YYYY-MM') = '2026-01'; -- 正确:用范围条件 SELECT * FROM payments WHERE paid_at >= '2026-01-01' AND paid_at < '2026-02-01';format的日志模板统一收口到一个函数,便于统一加前缀、脱敏、控制长度。测试用例要覆盖边界值:金额
0、0.99、负数、超大数;日期0001-01-01、闰年 2 月 29 日、跨时区。to_char在这些点上行为差异明显。跨 locale 的环境要在会话内显式设置,避免开发机与生产机输出不一致:
sqlSET lc_numeric = 'C'; SET lc_monetary = 'C';
下一节