数组声明与初始化
数组(Array)是 PostgreSQL 中最实用的复合类型之一:它允许在单个列中存储同类型的多个值,省去额外的关联表,并支持包含、重叠、切片等原生操作。本节讲解数组类型的声明语法、字面量写法、ARRAY[] 构造器、多维数组声明,以及 array_length / array_dims 的入门用法。
前置知识
阅读本节前,建议先了解:JSONB 应用场景
基础概念
数组(Array) 是同一数据类型元素的有序集合,存储在单个列值中。它不同于关系表:数组元素没有独立的行标识,不能让其他表通过外键引用某个元素。
| 概念 | 术语 | 说明 |
|---|---|---|
| 数组 | Array | 同一类型元素的有序集合,存于单列 |
| 元素类型 | Element Type | 数组内所有元素的数据类型,必须一致 |
| 维度 | Dimension | 数组的嵌套层数,一维/二维/… |
| 下标 | Subscript | 访问元素的位置序号,PostgreSQL 从 1 开始 |
| 数组字面量 | Array Literal | '{1,2,3}' 形式的文本表示 |
| 数组构造器 | Array Constructor | ARRAY[1,2,3] 形式的表达式 |
| 空数组 | Empty Array | 零个元素的数组,写作 '{}',不是 NULL |
| 数组类型名 | Array Type Name | integer[] 或 _int4(内部名) |
必须先理解数组与 NULL 的两层区别:
| 情形 | 写法 | 含义 |
|---|---|---|
| 整列为空 | NULL | 该列没有值,未知 |
| 零元素数组 | '{}' | 该列有一个值,这个值是空集合 |
| 元素为空 | '{1,NULL,3}' | 数组有 3 个位置,中间那个未知 |
-- 三者语义完全不同
SELECT
NULL::int[] AS null_array, -- 列值为 NULL
'{}'::int[] AS empty_array, -- 零元素数组
'{1,NULL,3}'::int[] AS with_null, -- 含 NULL 元素
array_length('{}'::int[], 1) AS empty_len, -- 空数组长度为 NULL
array_length(NULL::int[], 1) AS null_len; -- NULL 数组长度也是 NULL null_array | empty_array | with_null | empty_len | null_len
------------+-------------+------------+-----------+----------
| {} | {1,NULL,3} | |
(1 row)array_length 对空数组返回 NULL 而非 0,这是很多人第一次使用时踩到的坑。要判断"数组是否为空",应该用 cardinality(arr) = 0 或 arr = '{}'。
语法与用法
数组类型的声明有多种等价写法:
| 写法 | 示例 | 说明 |
|---|---|---|
| 类型名 + 方括号 | integer[] | 最常用,推荐 |
| 类型名 + 方括号 + 长度 | integer[3] | 长度仅作文档,不强制 |
| 前置方括号 | [3][3] integer | 兼容旧写法,不推荐 |
ARRAY 关键字 | ARRAY | 可写作 float ARRAY[4],冗长 |
| 内部类型名 | _int4 | 系统目录中的真实名字 |
重要:integer[3] 中的 3 不会限制数组长度,PostgreSQL 不强制数组维度约束。
-- 四种等价的一维数组声明
CREATE TABLE t_array_syntax (
col_a integer[], -- 推荐写法
col_b integer[3], -- 长度不强制,仅作说明
col_c integer ARRAY, -- 标准 SQL 写法
col_d integer ARRAY[3] -- 标准 SQL + 长度
);
-- 插入 5 个元素也能成功,证明 [3] 不生效
INSERT INTO t_array_syntax (col_a, col_b, col_c, col_d)
VALUES ('{1,2,3,4,5}', '{1,2,3,4,5}', '{1,2,3,4,5}', '{1,2,3,4,5}')
RETURNING col_a, col_b; col_a | col_b
-----------+-----------
{1,2,3,4,5} | {1,2,3,4,5}
(1 row)最简可运行示例——数组列建表与插入:
-- 事件表:一个事件可以有多个标签
CREATE TABLE events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}', -- 空数组作为默认值
scores INTEGER[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 三种插入数组的方式
INSERT INTO events (name, tags, scores) VALUES
('发布会', ARRAY['tech', 'launch', 'online'], ARRAY[90, 85, 78]), -- ARRAY 构造器
('培训营', '{edu,offline,workshop}', '{88,92,95}'), -- 字面量
('分享会', ARRAY['tech', 'offline'], ARRAY[70, 75]); -- ARRAY 构造器
SELECT id, name, tags, scores FROM events ORDER BY id; id | name | tags | scores
----+--------+---------------------------+------------
1 | 发布会 | {tech,launch,online} | {90,85,78}
2 | 培训营 | {edu,offline,workshop} | {88,92,95}
3 | 分享会 | {tech,offline} | {70,75}
(3 rows)数组支持的类型几乎涵盖所有内置类型,包括自定义类型、枚举、复合类型:
-- 各种元素类型的数组
CREATE TABLE t_element_types (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tags_num INTEGER[] NOT NULL DEFAULT '{}', -- 整数
tags_text TEXT[] NOT NULL DEFAULT '{}', -- 文本
prices NUMERIC(10,2)[] NOT NULL DEFAULT '{}', -- 精确小数
days DATE[] NOT NULL DEFAULT '{}', -- 日期
flags BOOLEAN[] NOT NULL DEFAULT '{}', -- 布尔
stamps TIMESTAMPTZ[] NOT NULL DEFAULT '{}' -- 带时区时间戳
);
INSERT INTO t_element_types (tags_num, tags_text, prices, days, flags, stamps)
VALUES (
'{1,2,3}',
'{a,b}',
'{19.90,299.00}',
'{2026-01-01,2026-02-14}',
'{true,false,true}',
'{"2026-01-01 10:00:00+08"}'
)
RETURNING tags_num, prices, days, flags; tags_num | prices | days | flags
----------+----------------+-----------------------+------------
{1,2,3} | {19.90,299.00} | {2026-01-01,2026-02-14} | {t,f,t}
(1 row)详细说明
数组字面量的书写规则
字面量用花括号包裹、逗号分隔。文本元素在含特殊字符时需要转义:
| 元素内容 | 写法 | 说明 |
|---|---|---|
| 普通文本 | '{a,b}' | 无需引号 |
| 含空格 | '{"hello world",b}' | 双引号包裹 |
| 含逗号 | '{"a,b",c}' | 双引号包裹 |
| 含双引号 | '{"say \"hi\"",c}' | 反斜杠转义内层双引号 |
| 含花括号 | '{"{x}",y}' | 双引号包裹 |
NULL 元素 | '{1,NULL,3}' | 大写 NULL 表示空元素 |
字符串 'NULL' | '{"NULL"}' | 加引号表示普通文本 NULL |
-- 各种转义场景
SELECT
'{a,b,c}'::text[] AS plain,
'{"hello world","x,y"}'::text[] AS quoted,
'{"say \"hi\""}'::text[] AS escaped_quote,
'{1,NULL,3}'::int[] AS with_null,
'{"NULL"}'::text[] AS literal_null_text; plain | quoted | escaped_quote | with_null | literal_null_text
---------------------+---------------------+---------------+------------+-------------------
{a,b,c} | {"hello world","x,y"} | {"say \"hi\""} | {1,NULL,3} | {"NULL"}
(1 row)反斜杠本身在标准符合字符串中需要转义。推荐对含特殊字符的数组使用 ARRAY[] 构造器,避免手工转义:
-- 用 ARRAY[] 更安全,无需关心转义规则
SELECT ARRAY['hello world', 'x,y', 'say "hi"'] AS constructed;
-- 用字符串拼接时容易出错
-- 错误!未加引号导致元素被拆开
SELECT '{hello world}'::text[]; -- 会尝试解析为 2 个元素,实际报错ERROR: malformed array literal: "{hello world}"
DETAIL: Unexpected " " character.ARRAY 构造器
ARRAY[...] 是表达式,与字面量不同:它接受任意表达式作为元素,能直接引用列,也能用子查询产出数组。
-- 元素是表达式,不是字面文本
SELECT ARRAY[1 + 1, 2 * 3, upper('abc')] AS from_expr;
-- 引用列构造数组
SELECT name, ARRAY[name, upper(name), length(name)::text] AS derived
FROM events
ORDER BY id; name | derived
--------+----------------------------
发布会 | {发布会,发布会,3}
培训营 | {培训营,培训营,3}
分享会 | {分享会,分享会,3}
(3 rows)长 3 的中文是长度 3 的字符数(length 返回字符数,不是字节数)。
-- 子查询产出数组
SELECT ARRAY(SELECT name FROM events ORDER BY id) AS all_names; all_names
------------------------
{发布会,培训营,分享会}
(1 row)ARRAY(SELECT ...) 会自动折叠多行为一个数组,等价于 array_agg,但更简洁:
-- 两种等价写法
SELECT ARRAY(SELECT name FROM events ORDER BY id) AS via_subquery;
SELECT array_agg(name ORDER BY id) AS via_agg FROM events;-- 空结果集产出空数组,而不是 NULL
SELECT ARRAY(SELECT name FROM events WHERE false) AS empty_result; empty_result
--------------
{}
(1 row)多维数组的声明与初始化
多维数组(Multidimensional Array) 在类型声明上和普通数组一样——维度不是类型的一部分。任意数组列都能存任意维度的值:
-- 同一个列可以存 1 维、2 维、3 维数组
CREATE TABLE t_matrix (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
grid INTEGER[]
);
INSERT INTO t_matrix (grid) VALUES
('{1,2,3}'), -- 1 维
('{{1,2},{3,4}}'), -- 2 维
('{{{1,2},{3,4}},{{5,6},{7,8}}}'); -- 3 维
SELECT id, grid, array_dims(grid) AS dims, array_ndims(grid) AS ndims
FROM t_matrix
ORDER BY id; id | grid | dims | ndims
----+---------------------------------+--------------+-------
1 | {1,2,3} | [1:3] | 1
2 | {{1,2},{3,4}} | [1:2][1:2] | 2
3 | {{{1,2},{3,4}},{{5,6},{7,8}}} | [1:2][1:2][1:2] | 3
(3 rows)用 ARRAY[] 构造多维数组时需要嵌套:
-- 嵌套构造器产出 2 维数组
SELECT ARRAY[[1, 2], [3, 4]] AS nested,
array_dims(ARRAY[[1, 2], [3, 4]]) AS dims;
-- 二维数组乘 10(逐元素运算)
SELECT ARRAY[[1, 2], [3, 4]] * 10 AS scaled; nested | dims
----------------+----------
{{1,2},{3,4}} | [1:2][1:2]
(1 row)
scaled
----------------
{{10,20},{30,40}}
(1 row)声明时可显式写出维度(同样不强制):
CREATE TABLE t_grid_decl (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
m1 INTEGER[], -- 等价,推荐:不写维度
m2 INTEGER[][] -- 冗余写法,PostgreSQL 视为 INTEGER[]
);
-- 两者在系统目录中完全相同
SELECT column_name, data_type, udt_name
FROM information_schema.columns
WHERE table_name = 't_grid_decl'
ORDER BY ordinal_position; column_name | data_type | udt_name
-------------+-----------+----------
id | bigint | int8
m1 | ARRAY | _int4
m2 | ARRAY | _int4
(2 rows)查看数组长度与维度
长度与维度查询函数一览:
| 函数 | 返回 | 示例输入 | 返回结果 |
|---|---|---|---|
array_length(arr, dim) | 指定维度的长度 | array_length('{{1,2},{3,4}}', 1) | 2 |
cardinality(arr) | 元素总数(所有维度) | cardinality('{{1,2},{3,4}}') | 4 |
array_dims(arr) | 维度范围文本 | array_dims('{{1,2},{3,4}}') | [1:2][1:2] |
array_ndims(arr) | 维度数量 | array_ndims('{{1,2},{3,4}}') | 2 |
array_lower(arr, dim) | 下界 | array_lower('{1,2,3}', 1) | 1 |
array_upper(arr, dim) | 上界 | array_upper('{1,2,3}', 1) | 3 |
-- 长度与维度对比演示
SELECT
array_length('{{1,2},{3,4}}', 1) AS dim1_len, -- 第一维长度
array_length('{{1,2},{3,4}}', 2) AS dim2_len, -- 第二维长度
cardinality('{{1,2},{3,4}}') AS total_elems, -- 元素总数
array_dims('{{1,2},{3,4}}') AS dims, -- 维度范围
array_ndims('{{1,2},{3,4}}') AS ndims; -- 维度数 dim1_len | dim2_len | total_elems | dims | ndims
----------+----------+-------------+--------------+-------
2 | 2 | 4 | [1:2][1:2] | 2
(1 row)array_length 与 cardinality 的关键区别:
SELECT
array_length('{}'::int[], 1) AS len_empty, -- NULL(空数组)
cardinality('{}'::int[]) AS card_empty, -- 0(明确表示零个)
array_length('{1,2,3}', 2) AS len_no_dim2, -- NULL(维度不存在)
cardinality('{1,2,3}') AS card_1d; -- 3 len_empty | card_empty | len_no_dim2 | card_1d
-----------+------------+-------------+---------
| 0 | | 3
(1 row)据此可以总结判空的最佳写法:
-- 推荐:cardinality 对空数组返回 0,可用于比较运算
SELECT count(*) FROM events WHERE cardinality(tags) = 0;
-- 危险:array_length 返回 NULL,条件永不成立
SELECT count(*) FROM events WHERE array_length(tags, 1) = 0; -- 恒为 0 行
-- 也可以直接与空数组比较
SELECT count(*) FROM events WHERE tags = '{}';非 1 起始下标的数组(由切片产生)会让 array_length 与直觉不符:
-- 切片后下标不再从 1 开始
SELECT
('{a,b,c,d,e}'::text[])[2:4] AS sliced,
array_dims(('{a,b,c,d,e}'::text[])[2:4]) AS slice_dims,
array_length(('{a,b,c,d,e}'::text[])[2:4], 1) AS slice_len; sliced | slice_dims | slice_len
------------+------------+-----------
{b,c,d} | [2:4] | 3
(1 row)实战示例
场景:为内容平台设计文章表,用数组存储标签与封面图列表,并把已有数据迁移为聚合数组。
第 1 步:建表
-- 文章表:标签多值、封面图有序列表
CREATE TABLE articles (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
tags TEXT[] NOT NULL DEFAULT '{}',
cover_images TEXT[] NOT NULL DEFAULT '{}',
view_counts INTEGER[] NOT NULL DEFAULT '{}', -- 近 N 日浏览趋势
published_at DATE,
CONSTRAINT chk_tags_len CHECK (cardinality(tags) <= 10) -- 用 cardinality 限制数量
);
-- 限制标签数量的正确方式(array_length 遇到空数组返回 NULL 会绕过约束)
-- 不推荐:CHECK (array_length(tags, 1) <= 10) -- 空数组时条件为 NULL,视为通过CREATE TABLE第 2 步:插入数据
INSERT INTO articles (title, slug, tags, cover_images, view_counts, published_at) VALUES
('PostgreSQL 18 新特性一览', 'pg18-features', ARRAY['database', 'postgresql', 'release'],
ARRAY['/img/pg18-1.png', '/img/pg18-2.png'], ARRAY[1200, 1350, 980], DATE '2026-01-10'),
('数组类型实战指南', 'array-guide', ARRAY['database', 'postgresql', 'tutorial'],
ARRAY['/img/array-1.png'], ARRAY[520, 610, 720, 880], DATE '2026-02-05'),
('范围类型与排期系统', 'range-scheduling', ARRAY['database', 'tutorial'],
ARRAY['/img/range-1.png', '/img/range-2.png'], ARRAY[300, 410], DATE '2026-03-01'),
('草稿:未发布内容', 'draft-post', '{}', '{}', '{}', NULL)
RETURNING id, title, cardinality(tags) AS tag_cnt, cardinality(cover_images) AS img_cnt; id | title | tag_cnt | img_cnt
----+----------------------------+---------+---------
1 | PostgreSQL 18 新特性一览 | 3 | 2
2 | 数组类型实战指南 | 3 | 1
3 | 范围类型与排期系统 | 2 | 2
4 | 草稿:未发布内容 | 0 | 0
(4 rows)第 3 步:查询与统计
-- 统计各标签出现次数(需要先展开数组)
SELECT tag, count(*) AS article_cnt
FROM articles, unnest(tags) AS tag
GROUP BY tag
ORDER BY article_cnt DESC, tag; tag | article_cnt
-------------+-------------
database | 3
tutorial | 2
postgresql | 2
release | 1
(4 rows)-- 用数组长度做业务筛选举例
SELECT
id,
title,
cardinality(tags) AS tag_cnt,
cardinality(view_counts) AS days,
view_counts[1] AS first_day_views -- 下标从 1 开始
FROM articles
WHERE cardinality(view_counts) > 0
AND published_at IS NOT NULL
ORDER BY cardinality(view_counts) DESC; id | title | tag_cnt | days | first_day_views
----+--------------------------+---------+------+-----------------
2 | 数组类型实战指南 | 3 | 4 | 520
1 | PostgreSQL 18 新特性一览 | 3 | 3 | 1200
3 | 范围类型与排期系统 | 2 | 2 | 300
(3 rows)第 4 步:从关系表迁移为数组
把"文章-标签"多对多关系聚合成数组:
-- 模拟原有关系表
CREATE TABLE article_tags (
article_id BIGINT NOT NULL,
tag TEXT NOT NULL,
position INTEGER NOT NULL, -- 保留顺序
PRIMARY KEY (article_id, tag)
);
INSERT INTO article_tags VALUES
(1, 'database', 1), (1, 'postgresql', 2), (1, 'release', 3),
(2, 'database', 1), (2, 'postgresql', 2), (2, 'tutorial', 3);
-- 用 array_agg 聚合成数组,按 position 保序
SELECT
a.id,
a.title,
array_agg(t.tag ORDER BY t.position) AS tags_ordered
FROM articles a
JOIN article_tags t ON t.article_id = a.id
GROUP BY a.id, a.title
ORDER BY a.id; id | title | tags_ordered
----+--------------------------+-----------------------------
1 | PostgreSQL 18 新特性一览 | {database,postgresql,release}
2 | 数组类型实战指南 | {database,postgresql,tutorial}
(2 rows)第 5 步:清理
DROP TABLE article_tags;
DROP TABLE articles;
DROP TABLE events;
DROP TABLE t_array_syntax;
DROP TABLE t_element_types;
DROP TABLE t_matrix;
DROP TABLE t_grid_decl;注意事项
- 下标从 1 开始,不是 0。这是与多数编程语言最大的差异。
-- 从 1 开始
SELECT ('{a,b,c}'::text[])[1] AS first, ('{a,b,c}'::text[])[3] AS third;
-- 错误!下标 0 返回 NULL,不报错,最容易埋下静默 bug
SELECT ('{a,b,c}'::text[])[0] AS zeroth; -- NULLinteger[3]不限制长度。PostgreSQL 忽略声明中的维度长度,需要限制请用CHECK (cardinality(col) <= 3)。
-- 不推荐:以为 [3] 能限制长度
CREATE TABLE t_bad_limit (tags TEXT[3]);
-- 推荐:用 CHECK + cardinality
CREATE TABLE t_good_limit (
tags TEXT[] NOT NULL DEFAULT '{}' CHECK (cardinality(tags) <= 3)
);array_length对空数组返回NULL,与cardinality返回0不同。做长度比较时优先用cardinality。
-- 错误!空数组时条件为 NULL,CHECK 视为通过,约束失效
ALTER TABLE t_good_limit ADD CONSTRAINT chk_bad CHECK (array_length(tags, 1) <= 3);
-- 正确
ALTER TABLE t_good_limit ADD CONSTRAINT chk_good CHECK (cardinality(tags) <= 3);- 数组元素类型必须一致。混合类型会被隐式转换或直接报错。
-- 报错:无法把文本转为整数
SELECT ARRAY[1, 2, 'abc'];
-- 可以:统一转为 text
SELECT ARRAY[1, 2, 'abc']::text[] AS mixed_as_text;ERROR: invalid input syntax for type integer: "abc"- 不能直接对数组的某个元素建索引。
CREATE INDEX ON t ((arr[1]))是表达式索引,不是"元素索引";数组的集合查询请用 GIN 索引。
-- 不推荐:对单元素建普通 btree 索引,无法支撑 @> 查询
CREATE INDEX idx_bad ON events ((tags[1]));
-- 推荐:数组集合查询用 GIN
CREATE INDEX idx_events_tags_gin ON events USING gin (tags);- 数组不适合存大量元素。元素过多会撑大行宽,触发 TOAST 压缩,且更新任一元素都要重写整行。参见 TOAST 机制。元素数超过几十个时,应改用关系表。
- 默认值不要用
NULL当"空数组"。语义混乱且需要到处判空,统一用DEFAULT '{}'。
-- 不推荐
tags TEXT[]
-- 推荐
tags TEXT[] NOT NULL DEFAULT '{}'- 文本数组字面量含空格或逗号必须加双引号,否则会解析失败或元素被拆错。
-- 错误!空格导致解析失败
SELECT '{hello world}'::text[];
-- 正确
SELECT '{"hello world"}'::text[];
-- 更推荐
SELECT ARRAY['hello world'];unnest在多数组并列使用时按位置配对(PostgreSQL 9.4+ 起对FROM中的多个unnest采用 ROWS FROM 语义),长度不同时短的一方补NULL:
-- 两个数组按位置配对展开
SELECT * FROM unnest(ARRAY['a','b','c'], ARRAY[1,2]) AS t(letter, num); letter | num
--------+-----
a | 1
b | 2
c |
(3 rows)最佳实践
- 优先考虑"真的需要数组吗"。数组适合:元素数量少(一般不超过几十个)、不需要独立查询/更新单个元素、不需要外键引用元素的场景。否则用关系表。
- 数组列一律
NOT NULL DEFAULT '{}',把"没有值"统一为"空数组",查询代码不必再判NULL。 - 用
cardinality而非array_length做长度判断与约束,避免空数组返回NULL造成约束失效。 - 限制数组元素数量,用
CHECK (cardinality(col) <= N),防止单行无限膨胀。
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
tags TEXT[] NOT NULL DEFAULT '{}' CHECK (cardinality(tags) <= 10),
image_urls TEXT[] NOT NULL DEFAULT '{}' CHECK (cardinality(image_urls) <= 8)
);- 需要"标签去重"时在写入前处理。数组不去重,
{a,a,b}是合法值。
-- 写入前在应用层去重,或用 SQL 归一化
INSERT INTO products (sku, tags)
VALUES ('SKU-1001', ARRAY(SELECT DISTINCT unnest(ARRAY['a', 'a', 'b'])))
RETURNING sku, tags;- 给需要集合查询的数组列建 GIN 索引,但要注意 GIN 会带来写放大,写入密集的表需权衡。
CREATE INDEX idx_products_tags_gin ON products USING gin (tags);
-- 验证索引被使用
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM products WHERE tags @> ARRAY['database'];- 需要保持元素顺序时显式排序。
array_agg不保证顺序,必须写ORDER BY。
-- 推荐:显式保序
SELECT array_agg(tag ORDER BY position) FROM article_tags WHERE article_id = 1;
-- 不推荐:顺序随机
SELECT array_agg(tag) FROM article_tags WHERE article_id = 1;- 迁移关系表到数组前先评估查询模式。若未来仍需"按标签反查、按标签分页、按标签统计",关系表 + 索引往往比数组更灵活。
- 展示给用户前用
array_to_string拼接,避免直接把{a,b,c}这种内部格式暴露到界面。
SELECT title, array_to_string(tags, ' / ') AS tag_text FROM articles ORDER BY id;下一节
继续学习:数组访问与修改