UNION Types Cannot Be Matched in Postgres: Fix
Chat2DB TeamYou combine two queries with UNION and PostgreSQL rejects the result before running anything:
ERROR: UNION types integer and text cannot be matched
LINE 1: SELECT id FROM orders UNION SELECT order_ref FROM refunds;
^Every column of a UNION result has a single data type. PostgreSQL looks at the matching column in each branch and tries to find one type that all of them can be converted to without an explicit cast. When the branches are from different type categories, such as numbers and strings, there is no such type, and you get SQLSTATE 42804 (datatype mismatch).
The same check runs for every construct that merges values into one column: CASE, COALESCE, ARRAY[...], VALUES, GREATEST, LEAST, INTERSECT and EXCEPT. The message names the construct, which is the first clue about where to look. This article explains the rules and the fixes, with examples run on PostgreSQL 18.6 and 14.24, where all messages below are identical.
The Example Tables
CREATE TABLE orders (
id int,
customer_id int,
amount numeric(10,2),
status text,
created_at timestamptz
);
CREATE TABLE refunds (
id bigint,
order_ref text,
amount numeric(12,4),
reason varchar(50),
created_on date
);
INSERT INTO orders VALUES (1, 10, 25.00, 'paid', '2026-11-01 10:00+00');
INSERT INTO refunds VALUES (7, '1', 5.0000, 'damaged', '2026-11-02');refunds.order_ref holds the order ID as text, a common design in tables fed from external systems.
Reading the Message
SELECT id, created_at FROM orders
UNION ALL
SELECT id, order_ref FROM refunds;ERROR: UNION types timestamp with time zone and text cannot be matched
LINE 3: SELECT id, order_ref FROM refunds;
^The two types in the message are the type found so far for that output column, then the type of the branch that does not fit. The caret points at the first expression that could not be matched, here order_ref. In this query the columns are simply in the wrong order: the second column of the first branch is a timestamp, and the second column of the second branch is the order reference. Lining up the select lists fixes it.
When the order is right and the types still differ, you need a cast. Decide which type the result column should have, then cast the branch that disagrees:
SELECT id, status FROM orders
UNION ALL
SELECT order_ref::int, reason FROM refunds; id | status
----+---------
1 | paid
1 | damaged
(2 rows)Casting text to int fails at run time if any order_ref is not a number. If that can happen, cast the other way, so that the column is text in both branches:
SELECT id::text AS id, status AS label FROM orders
UNION ALL
SELECT order_ref, reason FROM refunds
ORDER BY 1;What PostgreSQL Can Match Automatically
The rules (documented as "UNION, CASE, and Related Constructs" in the PostgreSQL manual) boil down to this: all branches must belong to the same type category, and within that category PostgreSQL picks the type that the others can be implicitly converted to. Some practical consequences:
- Integers of different sizes match.
intandbigintresolve tobigint:
SELECT pg_typeof(x)
FROM (SELECT id AS x FROM orders UNION ALL SELECT id FROM refunds) s; pg_typeof
-----------
bigint
bigint
(2 rows)- Numerics with different precision match.
numeric(10,2)andnumeric(12,4)resolve to plainnumeric. textandvarcharmatch, and resolve totext.dateandtimestamptzmatch, because a date can be implicitly converted to a timestamp. The result istimestamptz, and the dates become midnight in the session time zone:
SELECT id, created_at FROM orders
UNION ALL
SELECT id, created_on FROM refunds; id | created_at
----+------------------------
1 | 2026-11-01 02:00:00-08
7 | 2026-11-02 00:00:00-08
(2 rows)If you want dates, cast the timestamp branch with created_at::date instead.
- Numbers and strings never match. There is no implicit conversion between the numeric and string categories, so
integerandtextalways fail.
Choosing the Target Type
When two branches really hold different kinds of data, the cast you choose decides what the result means, so it is worth a moment of thought:
- Prefer the type the consumer expects. If the result feeds a report or an API that treats the column as a number, cast the text branch to a number and handle bad values first. If it is a display label, cast numbers and dates to text.
- Cast to the wider type within a category. Mixing
numeric(10,2)andnumeric(12,4)needs no cast, but if you add one, use the wider precision so that no values are rounded. - Be explicit about time zones. Casting
timestamptztodateuses the session time zone, so the same query can return different dates for different users. Use(created_at AT TIME ZONE 'UTC')::datewhen the day boundary matters. - Format text on purpose.
amount::textgives25.00, whileto_char(amount, 'FM999G990D00')gives a formatted string. Choose the form the reader needs rather than relying on the default output.
Casting in only one branch is usually enough, because the other branches already have the target type. Keep the casts in the branch whose data is "foreign" to the result, which also documents where the conversion happens.
Untyped Literals and NULL
String literals in quotes and bare NULL start out with no type (PostgreSQL calls it unknown). They take their type from the other branches, which leads to two surprises.
The first is that a literal is converted to the other branch's type, and that conversion can fail with a different error:
SELECT 1 UNION SELECT 'a';ERROR: invalid input syntax for type integer: "a"Here PostgreSQL decided the column is integer and tried to read 'a' as an integer. With an explicitly typed value, you get the matching error instead:
SELECT 1 UNION SELECT 'a'::text;ERROR: UNION types integer and text cannot be matchedThe second surprise involves several NULL branches. PostgreSQL resolves a chain of UNIONs two branches at a time, from left to right. Two untyped branches resolve to text among themselves, and then text cannot be matched with integer:
SELECT NULL UNION ALL SELECT NULL UNION ALL SELECT 1;ERROR: UNION types text and integer cannot be matched
LINE 1: SELECT NULL UNION ALL SELECT NULL UNION ALL SELECT 1;
^A single NULL branch is fine, because it simply takes the type of the other branch. The fix for the chain is to type the first NULL:
SELECT NULL::int UNION ALL SELECT NULL UNION ALL SELECT 1;This pattern shows up in generated SQL that pads missing columns with NULL, for example when building a report from several tables that do not all have every column. Give each padding NULL the type of the real column, such as NULL::numeric AS amount.
The Same Error in CASE, COALESCE, ARRAY and VALUES
The message changes its first word to match the construct.
CASE
SELECT CASE WHEN true THEN id ELSE status END FROM orders;ERROR: CASE types text and integer cannot be matchedAll THEN and ELSE results must share a type. A quoted literal in one branch is converted to the type of the others, so mixing a number with a word fails at run time instead:
SELECT CASE WHEN amount > 10 THEN amount ELSE 'small' END FROM orders;ERROR: invalid input syntax for type numeric: "small"If the result is meant to be a label, convert the number to text:
SELECT CASE WHEN amount > 10 THEN amount::text ELSE 'small' END AS size FROM orders; size
-------
25.00
(1 row)COALESCE
COALESCE(amount, 'n/a') is a classic for reports. It fails because 'n/a' cannot become a numeric:
SELECT COALESCE(amount, 'n/a') FROM orders;ERROR: invalid input syntax for type numeric: "n/a"Write COALESCE(amount::text, 'n/a'). The same applies to dates and timestamps: COALESCE(created_at, '') fails because an empty string is not a valid timestamp. Use NULL and let the client display it, or convert to text first.
ARRAY, GREATEST and Set Operations
SELECT ARRAY[1, 'x'::text];ERROR: ARRAY types integer and text cannot be matchedSELECT GREATEST(1, 'a'::text);ERROR: GREATEST types integer and text cannot be matchedSELECT id FROM orders INTERSECT SELECT order_ref FROM refunds;ERROR: INTERSECT types integer and text cannot be matchedVALUES
In a multi-row VALUES list, each column is resolved across all rows. Mixed rows usually fail on the literal conversion:
SELECT * FROM (VALUES (1, 'a'), ('b', 2)) AS v(x, y);ERROR: invalid input syntax for type integer: "b"A Different Error: Wrong Number of Columns
If one branch has more columns than the other, the error is about the count, not the types:
SELECT id, amount FROM orders
UNION ALL
SELECT id, amount, reason FROM refunds;ERROR: each UNION query must have the same number of columnsAdd a placeholder column (with a type) to the shorter branch, for example NULL::varchar AS reason.
A Note on json and UNION
UNION (without ALL) removes duplicates, which requires comparing values. The json type has no equality operator, so a UNION of json columns fails with could not identify an equality operator for type json. Use UNION ALL if duplicates are acceptable, or switch to jsonb, which can be compared.
Finding the Mismatched Column Quickly
In a long query with many columns, the caret in the LINE output is the fastest pointer. If the query is generated and you only have the message, compare the types branch by branch:
- Run each branch separately with
LIMIT 0in a client that shows column types, or wrap it asSELECT pg_typeof(col) FROM (...) s LIMIT 1for the suspicious column. - Line up the columns by position, not by name.
UNIONmatches columns by position, and the names in the result always come from the first branch. - Decide on a target type for each mismatched position and cast in the branch that disagrees.
A visual client helps with step 1. In Chat2DB (opens in a new tab), running each branch shows the result column types side by side, and the AI assistant can add the missing casts to a long UNION query for you. You can also use the browser version at app.chat2db.ai (opens in a new tab).
Summary
<CONSTRUCT> types A and B cannot be matchedmeans the branches of aUNION,CASE,COALESCE,ARRAY,VALUES,GREATESTor set operation have types from different categories.- Check the column order first; then cast the disagreeing branch to the type the result column should have.
- Integers, numerics, strings and date/time types match within their own category. Numbers and strings never match implicitly.
- Untyped literals take the other branch's type and may fail with
invalid input syntaxinstead. SeveralNULLbranches resolve totextfirst, so type the first one. - Names come from the first branch, and matching is by position.
For a related message that appears on INSERT and UPDATE, see column is of type integer but expression is of type text, and for the difference between the set operators, see UNION, INTERSECT and EXCEPT in PostgreSQL.
