Skip to content

CONCAT / || 拼接 ​

拼接(Concatenation)是把多个值首尾相接成一个字符串的最基础操作。PostgreSQL 提供了三种手段:SQL 标准操作符 ||、宽容空值的函数 concat()、以及带分隔符的 concat_ws()。三者在空值处理上行为完全不同,选错会静默产生错误结果。本节将逐一拆解它们的语义差异、类型转换规则与性能边界,让你在任何拼接场景下都能选对工具。

前置知识

阅读本节前,建议先了解:批量操作优化

基础概念 ​

拼接看起来只是「加法」,但 PostgreSQL 的拼接涉及三个正交维度:用什么语法、空值怎么算、非文本类型怎么转。

概念术语说明
拼接Concatenation将两个或多个字符串首尾相接
空值传染NULL Propagation任一侧为 NULL,整个表达式即为 NULL
空值跳过NULL Skipping忽略 NULL 参数,其余部分照常拼接
操作符重载Operator Overloading|| 按操作数类型分派到不同实现
可变参数VARIADIC函数接受任意数量同类型参数
隐式类型转换Implicit Cast非文本类型自动转为 text 参与拼接
伪类型Pseudo-Type"any" 这类只能出现在函数签名中的类型
字段上限MaxAllocSize单个字段最大约 1 GB,拼接超限会报错

三种拼接手段的核心差异:

写法属于NULL 处理接收参数典型用途
a || bSQL 标准操作符传染:结果变 NULL两个操作数拼接必填字段
concat(a, b, ...)PostgreSQL 扩展函数跳过:忽略 NULL任意数量、任意类型拼接可选字段
concat_ws(sep, a, b, ...)PostgreSQL 扩展函数跳过:忽略 NULL,且不补分隔符任意数量、任意类型拼接带分隔符的文本
format(fmt, a, b)PostgreSQL 扩展函数按模板:%s 遇 NULL 输出空串任意数量、任意类型需要精确控制格式

选择流程:

text
拼接的字段可能为 NULL 吗?
  ├─ 否 → 用 || (标准、最快、可走表达式索引)
  └─ 是 → 你希望结果是 NULL 还是忽略空值?
           ├─ 希望整体为 NULL → 仍用 ||
           └─ 希望忽略空值 → 需要分隔符吗?
                              ├─ 需要 → concat_ws(sep, ...)
                              └─ 不需要 → concat(...)

一句话记忆:|| 是「数据不全就整体作废」,concat 是「有几段拼几段」。

语法与用法 ​

sql
-- 操作符:两侧必须是可拼接的类型
表达式1 || 表达式2

-- 函数:可变参数,最少 1 个
concat(值1 [, 值2 [, ...]])
concat_ws(分隔符, 值1 [, 值2 [, ...]])

-- 模板函数:占位符 %s / %I / %L / %%
format(模板 [, 值1 [, ...]])

-- 相关配套函数
string_agg(值, 分隔符)          -- 聚合多行(见 SPLIT_PART / STRING_AGG 一节)
array_to_string(数组, 分隔符)    -- 数组转字符串
array_agg(值)                   -- 聚合成数组

最小可运行示例:

sql
-- 三种写法的直观对比
SELECT
  'postgresql' || '18'              AS by_operator,   -- 操作符
  concat('postgresql', 18)          AS by_concat,     -- 函数,数字自动转文本
  concat_ws('-', 'v', '18', 0)      AS by_concat_ws,  -- 带分隔符
  format('%s-%s', 'v', 18)          AS by_format;     -- 模板
text
 by_operator | by_concat  | by_concat_ws | by_format
-------------+------------+--------------+-----------
 postgresql18 | postgresql18 | v-18-0     | v-18

准备本节贯穿使用的示例表:

sql
-- 用户表:full_name / phone / bio 都可能为 NULL
CREATE TABLE users (
  id         INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  username   TEXT NOT NULL,
  full_name  TEXT,
  email      TEXT,
  phone      TEXT,
  city       TEXT,
  address    TEXT,
  bio        TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

INSERT INTO users (username, full_name, email, phone, city, address, bio) VALUES
  ('zhangsan', '张三',   'zhangsan@example.com', '13800000001', '北京市', '朝阳区建国路 1 号', 'DBA'),
  ('lisi',     '李四',   'lisi@example.com',     NULL,          '上海市', '浦东新区世纪大道 2 号', NULL),
  ('wangwu',   NULL,     'wangwu@example.com',   '13800000003', NULL,     NULL,   '后端开发'),
  ('zhaoliu',  '赵六',   NULL,                   NULL,          '广州市', NULL,   NULL);
sql
-- 对比:同一批数据,三种写法的输出差异
SELECT
  id,
  full_name || ' (' || username || ')'            AS op_style,
  concat(full_name, ' (', username, ')')          AS concat_style,
  concat_ws(' / ', city, address, phone)          AS ws_style
FROM users
ORDER BY id;
text
 id |       op_style        |     concat_style     |          ws_style
----+-----------------------+----------------------+----------------------------
  1 | 张三 (zhangsan)       | 张三 (zhangsan)      | 北京市 / 朝阳区建国路 1 号 / 13800000001
  2 | 李四 (lisi)           | 李四 (lisi)          | 上海市 / 浦东新区世纪大道 2 号
  3 |                       | wangwu ()            | 13800000003
  4 | 赵六 (zhaoliu)        | 赵六 (zhaoliu)       | 广州市

第 3 行是最值得注意的一行:|| 因为 full_name IS NULL 而让整列变空,concat 仍然拼出了 wangwu (),concat_ws 则干净地跳过了三个 NULL。

详细说明 ​

|| 操作符:SQL 标准写法 ​

|| 是 SQL 标准(SQL:1989 起)定义的字符串拼接操作符,同时也是 PostgreSQL 中重载最多的操作符之一。它有独立的索引可用性优势——优化器能识别 || 表达式并在表达式索引上做匹配。

sql
SELECT
  'a' || 'b'            AS t_t,        -- text || text
  'a' || 1              AS t_i,        -- text || anynonarray → text
  'n=' || 1.50::numeric AS t_n,        -- numeric 保留尾随零吗?
  'b=' || true          AS t_b,        -- boolean → text
  'd=' || DATE '2026-09-22' AS t_d;    -- date → text
text
 t_t | t_i |  t_n   | t_b  |     t_d
-----+-----+--------+------+------------
 ab  | a1  | n=1.50 | b=true | d=2026-09-22

注意 1.50::numeric 拼接后保留了尾随零 1.50——numeric 的文本输出遵循其 scale,这是与 float8 不同的行为(1.50::float8 输出为 1.5)。

|| 的空值行为必须牢记:

sql
SELECT
  'a' || NULL          AS with_null,
  NULL || 'b'          AS null_left,
  NULL || NULL         AS all_null,
  '' || 'x'            AS empty_left,
  'x' || ''            AS empty_right,
  '' || ''             AS both_empty;
text
 with_null | null_left | all_null | empty_left | empty_right | both_empty
-----------+-----------+----------+------------+-------------+------------
           |           |          | x          | x           |

结论:空串 '' 是「有值」,NULL 是「无值」。'' || 'x' 得到 'x',而 NULL || 'x' 得到 NULL。这个区别在生成报表文本时尤其关键。

concat:忽略 NULL 的可变参数函数 ​

concat 的签名是 concat(VARIADIC "any") → text。两个要点:

  1. 参数是伪类型 "any",意味着任何类型都能直接传入,由函数内部调用该类型的输出函数转为文本;
  2. 由于 "any" 伪类型不能有真正的 VARIADIC 数组形式,concat 不能直接展开数组。
sql
SELECT
  concat('a', NULL)                     AS skip_null,
  concat('a', NULL, 'b')                AS middle_null,
  concat(NULL, NULL)                    AS all_null,
  concat('count=', 42, ', ok=', true)   AS mixed,
  concat()                              AS no_args;
text
 skip_null | middle_null | all_null |     mixed     | no_args
-----------+-------------+----------+---------------+---------
 a         | ab          |          | count=42, ok=true |

注意 concat(NULL, NULL) 返回空串而非 NULL,因为两个参数都被跳过,剩余部分为空。这与 || 截然不同,是迁移代码时最容易踩的坑。

concat 的隐式转换能力很强,但也带来歧义:

sql
-- 数字与布尔会被转成文本
SELECT concat(1, 2, 3) AS num_concat, concat(1, 2, 3, '') AS forced_text;
text
 num_concat | forced_text
------------+-------------
 123        | 123

concat(1, 2, 3) 得到 123 而不是 6——它从不做算术。若想要 6,必须先算再拼:

sql
SELECT (1 + 2 + 3)::text AS computed;   -- 6

concat_ws:带分隔符且跳过 NULL ​

concat_ws 是 concat with separator 的缩写,签名 concat_ws(sep text, VARIADIC "any") → text。

它的独特之处在于:分隔符只加在有值的参数之间,不会产生 a,,b 或 a--b 这类空位。

sql
SELECT
  concat_ws(', ', '北京市', NULL, '朝阳区', NULL, '建国路 1 号') AS address,
  concat_ws('-', 'a', NULL, 'b', NULL)                          AS simple,
  concat_ws('-', NULL, NULL)                                    AS all_null,
  concat_ws(NULL, 'a', 'b')                                     AS null_sep;
text
           address           | simple | all_null | null_sep
-----------------------------+--------+----------+----------
 北京市, 朝阳区, 建国路 1 号  | a-b    |          |

两个边界情况:

  • 所有参数都为 NULL:结果为空串 ''(因为没有任何「有值」的参数需要拼接);
  • 分隔符本身为 NULL:整个结果为 NULL——分隔符不会被跳过。这是 concat_ws 唯一的 NULL 传染路径,用变量做分隔符时要小心。

与 || 相比,concat_ws 在地址、全名、标签这类「字段可选」的场景几乎总是更优选择:

sql
-- 不推荐:任何一个字段为 NULL,整条地址就没了
SELECT address_line1 || ', ' || address_line2 || ', ' || city AS addr_bad FROM users;

-- 推荐:干净地跳过缺失部分
SELECT concat_ws(', ', address_line1, address_line2, city) AS addr_ok FROM users;

非文本类型如何被转换 ​

|| 与 concat 都会对操作数做隐式转换,但规则来源不同:

操作数类型|| 的处理concat 的处理
text / varchar / char直接拼接直接拼接
integer / numerictext || anynonarray,转文本转文本
boolean转 t / f 吗?不,输出函数的 true / falsetrue / false
date / timestamp依赖 DateStyle 设置同左
jsonb转文本转文本
bytea转义形式转义形式
数组触发数组拼接重载输出数组字面量 {...}

布尔类型在 || 下的行为值得单独验证:

sql
SELECT
  true || 'x'            AS bool_by_op,      -- 走 anynonarray 重载
  'x' || true            AS bool_by_op2,
  concat(true, 'x')      AS bool_by_fn;
text
 bool_by_op | bool_by_op2 | bool_by_fn
------------+-------------+-----------
 truex      | xtrue       | truex

若业务要求输出「是/否」,必须显式转换:

sql
SELECT
  concat('已支付:', CASE WHEN true THEN '是' ELSE '否' END) AS cn_bool,
  format('已支付:%s', CASE WHEN false THEN '是' ELSE '否' END) AS cn_bool_fmt;
text
   cn_bool   | cn_bool_fmt
-------------+-------------
 已支付:是  | 已支付:否

数组与 ||:容易被忽略的重载 ​

|| 在 PostgreSQL 中还被重载为数组拼接操作符。当任意一侧是数组时,字符串拼接的重载会输给数组拼接:

sql
SELECT
  ARRAY[1, 2] || ARRAY[3]        AS arr_arr,      -- 数组接数组
  ARRAY[1, 2] || 3               AS arr_elem,     -- 元素追加到尾部
  0 || ARRAY[1, 2]               AS elem_arr,     -- 元素插入到头部
  ARRAY['a'] || ARRAY['b', 'c']  AS text_arr;
text
  arr_arr  | arr_elem | elem_arr |  text_arr
-----------+----------+----------+------------
 {1,2,3}   | {1,2,3}  | {0,1,2}  | {a,b,c}

另一个高频错误:两侧都不是字符串时,|| 根本无重载可用,直接报错。

sql
-- 错误!两个整数都没有数组类型,也没有 anynonarray 可用的字符串侧
SELECT 1 || 2;
-- ERROR:  operator does not exist: integer || integer
-- LINE 1: SELECT 1 || 2;
--                ^
-- HINT:  No operator matches the given name and argument types.
--         You might need to add explicit type casts.

这与 MySQL(SELECT 1 || 2 得 12)、SQLite(得 12)行为完全不同——它们是隐式转字符串,PostgreSQL 要求至少一侧是字符串类型才会走 text || anynonarray 重载:

sql
-- 正确:至少一侧显式转文本
SELECT 1 || 2::text                 AS int_text,    -- 12(右侧先转 text)
       '1' || 2                     AS text_int,    -- 12(左侧是 text 字面量)
       (1::text || 2::text)         AS both_cast,   -- 12(两侧都显式转)
       concat(1, 2)                 AS via_concat;  -- 12(concat 自身会转)
text
 int_text | text_int | both_cast | via_concat
----------+----------+-----------+------------
 12       | 12       | 12        | 12

而如果两个整数都被"误当"成数组元素,还可能触发数组拼接重载,得到意想不到的数组而不是字符串。所以生成编号、拼接数字字段时,务必显式 ::text 或 to_char()。

易错点来了:下面这个写法看起来想拼字符串,实际得到的是数组:

sql
-- 错误!期望得到 'a,b,c',实际得到数组
SELECT 'a' || ARRAY['b', 'c'];
text
   ?column?
--------------
 {a,b,c}

要得到字符串,必须显式指定类型或用函数:

sql
SELECT
  'a' || ARRAY['b', 'c']::text            AS by_cast_text,   -- 数组整体转文本
  ARRAY['a', 'b', 'c']::text              AS whole,
  array_to_string(ARRAY['a', 'b', 'c'], ',') AS joined;      -- 推荐
text
 by_cast_text |  whole  | joined
--------------+---------+--------
 {a,b,c}      | {a,b,c} | a,b,c

只有 array_to_string 才得到真正的 a,b,c。而 concat 接收数组时同样输出字面量:

sql
SELECT concat(ARRAY[1, 2, 3]) AS arr_via_concat;
text
 arr_via_concat
----------------
 {1,2,3}

若确实想把数组元素逐个拼进 concat,需要展开:

sql
-- 用 unnest 展开后聚合,或直接 string_agg
SELECT string_agg(part, '-') AS from_array
FROM unnest(ARRAY['a', 'b', 'c']) AS part;
text
 from_array
------------
 a-b-c

format:需要精确控制时 ​

当拼接涉及占位对齐、字面量转义、标识符引用时,format 比反复用 || 更安全:

sql
SELECT
  format('用户 %s 于 %s 注册', 'zhangsan', DATE '2026-09-22')     AS s_plain,
  format('金额:%s 元', 1234.5)                                  AS s_num,
  format('表名 %%s 不替换')                                       AS s_escape,
  format('空值:[%s]', NULL)                                     AS s_null,
  format('标识符:%I', 'select')                                 AS s_ident,   -- 自动加双引号
  format('字面量:%L', 'it''s ok')                               AS s_literal; -- 自动转义
text
          s_plain           |   s_num     |   s_escape    | s_null |  s_ident  |    s_literal
----------------------------+-------------+---------------+--------+-----------+------------------
 用户 zhangsan 于 2026-09-22 注册 | 金额:1234.5 元 | 表名 %s 不替换   | 空值:[] | 标识符:"select" | 字面量:'it''s ok'

%s 遇到 NULL 输出空串(等同于 concat 的跳过语义);%I 与 %L 会做 SQL 安全转义,是动态 SQL 拼装的正确方式。

拼接性能与 1 GB 上限 ​

字符串拼接的时间复杂度与结果长度相关。一行一行累积拼接(acc || next)在循环中会产生 O(n²) 的拷贝成本。PostgreSQL 内部对少数次拼接有优化,但大批量累积应交给聚合函数:

sql
-- 不推荐:逐行拼接,每行都要复制已有结果
DO $$
DECLARE
  acc text := '';
BEGIN
  FOR i IN 1..10000 LOOP
    acc := acc || i::text;   -- O(n²) 拷贝
  END LOOP;
END $$;

-- 推荐:一次聚合完成
SELECT string_agg(i::text, '')
FROM generate_series(1, 10000) AS i;

单个字段(text / varchar)的上限受 MaxAllocSize 限制,约为 1 GB(1073741823 字节)。超过会直接报错:

sql
-- 会报错:requested length too large
SELECT repeat('a', 1000000000) || repeat('b', 1000000000);
text
ERROR:  invalid memory alloc request size 2000000000

在生成大文本(导出 CSV、拼接大 JSON)时,务必分批处理,不要试图一次性拼出超过 1 GB 的值。

拼接与索引:表达式索引 ​

拼接结果可以被索引,前提是表达式必须完全确定:

sql
-- 创建索引前先确认表达式可用于索引
CREATE INDEX idx_users_fullkey ON users ((username || '@' || coalesce(city, 'unknown')));
ANALYZE users;

-- 查询时必须写成与索引完全一致的表达式,否则用不上
EXPLAIN (COSTS OFF)
SELECT id FROM users WHERE username || '@' || coalesce(city, 'unknown') = 'zhangsan@北京市';
text
                        QUERY PLAN
----------------------------------------------------------
 Index Only Scan using idx_users_fullkey on users
   Index Cond: ((username || '@'::text || COALESCE(city, 'unknown'::text)) = 'zhangsan@北京市'::text)

注意三点:

  1. coalesce 是必需的——NULL 参与的表达式中,NULL 值不会被传统 B-tree 索引收录(除非 NULLS NOT DISTINCT 索引);
  2. 查询表达式必须与索引定义逐字符一致,包括 coalesce 的默认值;
  3. concat 是 STABLE 而非 IMMUTABLE?实际上 concat 被标记为 STABLE,因此不能用于表达式索引,只有 || 可以。

验证函数易变性:

sql
SELECT
  p.proname,
  CASE p.provolatile
    WHEN 'i' THEN 'IMMUTABLE'
    WHEN 's' THEN 'STABLE'
    WHEN 'v' THEN 'VOLATILE'
  END AS volatility
FROM pg_proc p
WHERE p.proname IN ('concat', 'concat_ws', 'format')
ORDER BY p.proname;
text
  proname  | volatility
-----------+------------
 concat    | STABLE
 concat_ws | STABLE
 format    | STABLE

concat 之所以是 STABLE,是因为其结果依赖 DateStyle 等会话参数——同一个 timestamptz 在不同 DateStyle 下输出文本不同。这直接决定了表达式索引必须用 || 配合 ::text。

拼接与排序规则 ​

拼接本身不做比较,但拼接结果参与 ORDER BY / DISTINCT 时受排序规则(Collation)影响:

sql
SELECT concat('a', 'B') AS v, upper(concat('a', 'B')) AS u;
text
 v  | u
----+----
 aB | AB

如果拼接用于唯一键(例如 order_no = channel || '-' || id),务必确认排序规则不会把视觉上不同的值判为相等。在 C 排序规则下 'a' = 'A' 为假,在 en_US.UTF-8 下同样为假,但在某些大小写不敏感的 ICU 规则下可能为真。

sql
-- 查看当前排序规则
SELECT datname, datcollate, datctype FROM pg_database WHERE datname = current_database();
text
 datname |  datcollate  |   datctype
---------+--------------+--------------
 demo    | zh_CN.UTF-8  | zh_CN.UTF-8

实战示例 ​

场景一:生成用户展示卡片 ​

需求:为后台列表生成一行展示文本,缺失字段要优雅跳过,格式为 姓名 (用户名) · 城市 · 电话。

sql
-- 步骤 1:先看清数据分布,确认哪些列有 NULL
SELECT
  count(*)                                        AS total,
  count(*) FILTER (WHERE full_name IS NULL)       AS no_name,
  count(*) FILTER (WHERE city      IS NULL)       AS no_city,
  count(*) FILTER (WHERE phone     IS NULL)       AS no_phone
FROM users;
text
 total | no_name | no_city | no_phone
-------+---------+---------+----------
     4 |       1 |       1 |         2
sql
-- 步骤 2:用 concat_ws 一次成型,彻底规避 NULL 问题
SELECT
  id,
  concat_ws(' · ', concat_ws(' ', full_name, '(' || username || ')'), city, phone) AS card
FROM users
ORDER BY id;
text
 id |                  card
----+------------------------------------------
  1 | 张三 (zhangsan) · 北京市 · 13800000001
  2 | 李四 (lisi) · 上海市
  3 |  (wangwu) · 13800000003
  4 | 赵六 (zhaoliu) · 广州市

第 3 行展示了嵌套 concat_ws 的一个副作用:内层 concat_ws(' ', NULL, '(wangwu)') 返回 ' (wangwu)'(只有 NULL 被跳过,前面的分隔符没有参与判断),因此多了一个前导空格。这正是内层拼接的典型陷阱。

sql
-- 步骤 3:修正——内层也先构造完整片段,再统一由外层加分隔符
SELECT
  id,
  concat_ws(' · ', nullif(concat_ws(' ', full_name, nullif('(' || username || ')', '()')), ''), city, phone) AS card
FROM users
ORDER BY id;
text
 id |                  card
----+------------------------------------------
  1 | 张三 (zhangsan) · 北京市 · 13800000001
  2 | 李四 (lisi) · 上海市
  3 | wangwu · 13800000003
  4 | 赵六 (zhaoliu) · 广州市

结论:当内层拼接带有固定文本(如括号)时,应改用 nullif(..., '') 把「只剩空壳」的结果重新变回 NULL,外层 concat_ws 才能正确跳过。

场景二:拼接业务主键与批量查询 ​

需求:订单号由渠道前缀 + 日期 + 序号构成,要求定长、可比较、可索引。

sql
CREATE TABLE orders (
  id         INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_no   TEXT           NOT NULL,
  channel    TEXT           NOT NULL,
  seq_no     INTEGER        NOT NULL,
  amount     NUMERIC(12, 2) NOT NULL,
  created_at TIMESTAMPTZ    NOT NULL
);

-- 步骤 1:用 lpad 保证定长,日期用 to_char 固定格式
INSERT INTO orders (order_no, channel, seq_no, amount, created_at)
SELECT
  'ON-' || upper(channel) || '-' || to_char(d, 'YYYYMMDD') || '-' || lpad(seq_no::text, 6, '0'),
  channel,
  seq_no,
  round((random() * 1000)::numeric, 2),
  d::timestamptz
FROM (
  SELECT c AS channel, s AS seq_no, DATE '2026-09-01' + (s % 3) AS d
  FROM unnest(ARRAY['web', 'app', 'pos']) AS c
  CROSS JOIN generate_series(1, 5) AS s
) t;

-- 步骤 2:验证定长与唯一性
SELECT
  count(*)                                  AS total,
  count(DISTINCT order_no)                   AS distinct_no,
  min(length(order_no))                      AS min_len,
  max(length(order_no))                      AS max_len
FROM orders;
text
 total | distinct_no | min_len | max_len
-------+-------------+---------+---------
    15 |          15 |      23 |      23
sql
-- 步骤 3:拼接出的键可以走索引
CREATE UNIQUE INDEX idx_orders_no ON orders (order_no);
ANALYZE orders;

EXPLAIN (COSTS OFF)
SELECT id, amount FROM orders WHERE order_no = 'ON-WEB-20260901-000001';
text
                                QUERY PLAN
---------------------------------------------------------------------------
 Index Scan using idx_orders_no on orders
   Index Cond: (order_no = 'ON-WEB-20260901-000001'::text)
sql
-- 步骤 4:按前缀做范围扫描(拼接键的常见用法)
SELECT channel, count(*) AS cnt
FROM orders
WHERE order_no LIKE 'ON-APP-%'
GROUP BY channel;
text
 channel | cnt
---------+-----
 app     |   5

因为前缀固定且定长,LIKE 'ON-APP-%' 这类查询可以被 B-tree 索引优化(前提是 C 排序规则或 text_pattern_ops)。若库的排序规则是 zh_CN.UTF-8,需要额外建索引:

sql
CREATE INDEX idx_orders_no_pattern ON orders (order_no text_pattern_ops);

注意事项 ​

  1. || 遇到 NULL 会让整个结果变 NULL,这是最常见的静默错误来源:

    sql
    -- 错误!full_name 为 NULL 时整列为空
    SELECT full_name || ' <' || email || '>' FROM users;
    -- 正确
    SELECT concat_ws(' ', full_name, '<' || email || '>') FROM users;
  2. concat 会把所有参数转为文本,concat(1, 2, 3) 得到 '123' 而非 '6',需要计算时先算后拼。

  3. concat_ws 的分隔符为 NULL 时整体为 NULL,用变量做分隔符必须保证非空:

    sql
    -- 错误!sep 为 NULL 时结果全丢
    SELECT concat_ws(sep, a, b) FROM t;
    -- 正确
    SELECT concat_ws(coalesce(sep, ','), a, b) FROM t;
  4. || 与数组混用会触发数组拼接重载,'a' || ARRAY['b'] 得到 {a,b} 而非字符串,必须显式 ::text。

  5. concat / concat_ws / format 都是 STABLE 不是 IMMUTABLE,不能用于表达式索引;表达式索引只能用 || 与 ::text。

  6. 表达式索引中的表达式必须与查询逐字符一致,coalesce 的默认值写错一个字符就用不上索引。

  7. 隐式类型转换依赖会话参数,DateStyle / IntervalStyle / extra_float_digits 不同,拼接结果的文本可能不同,服务端与客户端环境不一致时会导致对不上。

  8. 不要用字符串拼接构造 SQL,务必用 format 的 %I / %L:

    拼接用户输入到 SQL 文本属于高危操作

    只要拼接的片段里有用户可控内容,一个单引号或分号就能改写语句,务必改用参数化查询或 format('%I') / format('%L')。

    sql
    -- 错误!SQL 注入风险
    EXECUTE 'SELECT * FROM ' || tbl_name;
    -- 正确
    EXECUTE format('SELECT * FROM %I', tbl_name);
  9. 单字段上限约 1 GB,超限报 invalid memory alloc request size,大文本导出必须分批。

  10. 循环中反复 acc := acc || x 是 O(n²),超过几千次就该换成 string_agg 或 array_to_string。

  11. concat 遇到 bytea 会输出转义形式(如 \x48656c6c6f),若想拼二进制请用 bytea || bytea 而不是 concat。

  12. char(n) 参与拼接时尾随空格的处理不一致:char(n) 的尾部空格在存储层被填充,拼接时通常被裁剪,但对 length 的影响需实测确认。

最佳实践 ​

  1. 默认用 concat_ws 拼接带分隔符的可选字段,它的空值跳过语义最贴合业务直觉。
  2. 必填字段拼接用 ||,语义清晰、性能更好、且可用于表达式索引。
  3. 需要精确格式控制时用 format,尤其是动态 SQL、日志模板、对齐输出。
  4. 拼接前统一类型:::text 显式转换优于依赖隐式转换,能避免 DateStyle 等会话参数带来的差异。
  5. 数字拼接前想清楚格式,金额用 to_char(amount, 'FM999999990.00') 控制小数位,不要直接 || amount。
  6. 布尔值一定要转成业务文案(CASE WHEN ... THEN '是' ELSE '否' END),true / false 几乎不会出现在最终报表里。
  7. 数组转字符串用 array_to_string,不要用 || 或 concat。
  8. 生成业务键时保证定长,用 lpad / to_char 固定宽度,便于范围查询与排序。
  9. 唯一键用 concat_ws 时注意它跳过 NULL,concat_ws('-', 'a', NULL, 'b') 与 concat_ws('-', 'a', 'b') 结果相同,可能造成键碰撞——必要时用 coalesce(field, '') 显式占位。
  10. 批量拼接优先 string_agg,它是一次聚合,避免逐行累积的拷贝开销。
  11. 给表达式索引写查询时,直接把索引定义的表达式复制过去,不要「凭记忆重写」。
  12. 动态 SQL 只用 format + %I / %L,永远不要手工拼接标识符或字面量。

下一节

继续学习:SUBSTRING / LEFT / RIGHT / TRIM

参考链接 ​