Postgres inet vs cidr: IP Address Types Explained
Chat2DB TeamMany applications store IP addresses: login audit tables, API rate limiters, firewall allow lists, device inventories. The easy choice is a varchar(45) column, and it works until someone asks "which of these requests came from our office network?" At that point you are parsing strings in SQL, and you cannot index the question.
PostgreSQL has two native network address types, inet and cidr, plus a full set of functions and operators for subnet math. This guide explains the difference between them, the errors you will run into, and how to index containment queries. Every example was run on PostgreSQL 18.
inet and cidr in one sentence each
inetholds a host address and, optionally, the netmask of the network it lives in.192.168.10.21and192.168.10.22/24are both valid.cidrholds a network. Every bit to the right of the mask must be zero, so192.168.10.0/24is valid but192.168.10.37/24is not.
Both types accept IPv4 and IPv6 in the same column, and both take 7 bytes for IPv4 or 19 bytes for IPv6. That is smaller than the text form of most addresses.
Creating tables
Here is a small inventory of devices and a list of networks that are allowed to reach an internal service:
CREATE TABLE devices (
id serial PRIMARY KEY,
name text,
ip inet NOT NULL
);
INSERT INTO devices (name, ip) VALUES
('web-1', '192.168.10.21'),
('web-2', '192.168.10.22/24'),
('db-1', '10.20.0.5'),
('vpn', '2001:db8::7'),
('office-printer', '192.168.11.40');
CREATE TABLE allowed_networks (
id serial PRIMARY KEY,
label text,
net cidr NOT NULL
);
INSERT INTO allowed_networks (label, net) VALUES
('office', '192.168.10.0/24'),
('datacenter', '10.20.0.0/16'),
('ipv6-lab', '2001:db8::/32');Notice that web-2 was stored with a /24. An inet value remembers the mask you gave it, and when there is no mask it is a single host (/32 for IPv4, /128 for IPv6).
The "invalid cidr value" error
Try to store a host address in the cidr column:
INSERT INTO allowed_networks (label, net) VALUES ('bad', '192.168.10.37/24');ERROR: invalid cidr value: "192.168.10.37/24"
LINE 1: ... INTO allowed_networks (label, net) VALUES ('bad','192.168.1...
^
DETAIL: Value has bits set to right of mask.The SQLSTATE is 22P02 (invalid_text_representation). PostgreSQL rejects the value because .37 is a host bit inside a /24 network. You have three ways to fix it:
-
Store the network address instead.
network()computes it for you:SELECT network('192.168.10.37/24'::inet); -- 192.168.10.0/24Casting an
inettocidrdoes the same thing:'192.168.10.21/24'::inet::cidrreturns192.168.10.0/24. -
Shorten the mask if you meant a single host:
192.168.10.37/32. -
Use
inetfor the column if you really want to keep both the host and its mask, for example for an interface address.
When you import data from a CSV, you can check the rows before inserting them. pg_input_is_valid() and pg_input_error_info() (PostgreSQL 16 and later) test a string without raising an error:
SELECT * FROM pg_input_error_info('192.168.10.37/24', 'cidr'); message | detail | hint | sql_error_code
----------------------------------------+-------------------------------------+------+----------------
invalid cidr value: "192.168.10.37/24" | Value has bits set to right of mask. | | 22P02The other common error is plain bad input. Both of these fail with invalid input syntax for type inet:
SELECT 'not-an-ip'::inet;
SELECT '192.168.1.300'::inet;Leading or trailing spaces are rejected too, so ' 10.0.0.2' from a sloppy export fails. Trim the text before casting.
cidr shorthand and classful defaults
The cidr type accepts abbreviated input. If you leave out the mask, PostgreSQL infers one from the old address classes, and missing octets are filled with zeros:
SELECT '10'::cidr, '192.168'::cidr, '128'::cidr, '10.1.2'::cidr;
-- 10.0.0.0/8 | 192.168.0.0/24 | 128.0.0.0/16 | 10.1.2.0/24That is convenient in a psql session but surprising in application code, so always write the mask explicitly. inet is stricter: without a mask it requires all four octets, so '10.1.2'::inet is an error.
The reverse direction is abbrev(), which prints a cidr in its shortest form:
SELECT abbrev('10.20.0.0/16'::cidr); -- 10.20/16Functions you will use every day
SELECT ip, host(ip), masklen(ip), network(ip), broadcast(ip), netmask(ip), family(ip)
FROM devices ORDER BY id; ip | host | masklen | network | broadcast | netmask | family
------------------+---------------+---------+------------------+-------------------+-----------------------------------------+--------
192.168.10.21 | 192.168.10.21 | 32 | 192.168.10.21/32 | 192.168.10.21 | 255.255.255.255 | 4
192.168.10.22/24 | 192.168.10.22 | 24 | 192.168.10.0/24 | 192.168.10.255/24 | 255.255.255.0 | 4
10.20.0.5 | 10.20.0.5 | 32 | 10.20.0.5/32 | 10.20.0.5 | 255.255.255.255 | 4
2001:db8::7 | 2001:db8::7 | 128 | 2001:db8::7/128 | 2001:db8::7 | ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff | 6
192.168.11.40 | 192.168.11.40 | 32 | 192.168.11.40/32 | 192.168.11.40 | 255.255.255.255 | 4A few details matter in practice:
host()returns text without the mask. Use it when you need the bare address in output.text()always includes the mask:text('10.0.0.1'::inet)returns10.0.0.1/32. A plain cast'10.0.0.1'::inet::textreturns the same thing, which is why string comparisons againstip::textoften fail.set_masklen()changes the mask. Oninetit keeps the host bits (set_masklen('192.168.10.21/24'::inet, 16)is192.168.10.21/16); oncidrit zeroes them (192.168.0.0/16).inet_merge(a, b)returns the smallest network that contains both addresses:inet_merge('192.168.10.21', '192.168.11.40')is192.168.10.0/23.
If you want to check any value without opening psql, the free Postgres inet & cidr calculator (opens in a new tab) shows all of these results in the browser and reproduces Postgres error messages exactly.
Containment operators
This is the reason to use the native types. PostgreSQL defines five subnet operators:
| Operator | Meaning |
|---|---|
a << b | a is strictly contained by b |
a <<= b | a is contained by or equal to b |
a >> b | a strictly contains b |
a >>= b | a contains or equals b |
a && b | either contains the other |
Which devices are on an allowed network?
SELECT d.name, d.ip, a.label
FROM devices d
LEFT JOIN allowed_networks a ON d.ip <<= a.net
ORDER BY d.id; name | ip | label
----------------+------------------+------------
web-1 | 192.168.10.21 | office
web-2 | 192.168.10.22/24 | office
db-1 | 10.20.0.5 | datacenter
vpn | 2001:db8::7 | ipv6-lab
office-printer | 192.168.11.40 |The printer at 192.168.11.40 is outside 192.168.10.0/24, so it has no match. Mixing IPv4 and IPv6 in one query is safe: an IPv4 address is never contained by an IPv6 network, and the operators simply return false.
Equality is different from containment. '192.168.10.21'::inet = '192.168.10.21/24'::inet is false, because the masks differ, while '192.168.10.21'::inet << '192.168.10.0/24' is true. If you deduplicate addresses, compare host(ip) or normalize the mask on insert.
Indexing containment queries
A B-tree index on an inet column supports = and ordering, and the planner can use it for << and <<= with a constant on the right by turning the condition into a range (ip >= '192.168.10.0/24' AND ip <= '192.168.10.255'). For general containment, including joins and >>= lookups against a table of networks, use a GiST index with the inet_ops operator class:
CREATE INDEX devices_ip_gist ON devices USING gist (ip inet_ops);
EXPLAIN (COSTS OFF)
SELECT * FROM devices WHERE ip <<= '192.168.10.0/24';Index Scan using devices_ip_gist on devices
Index Cond: (ip <<= '192.168.10.0/24'::inet)(On a five-row table the planner prefers a sequential scan; the plan above was taken with enable_seqscan = off to show that the index is usable.) For the "which network does this address belong to" lookup, index the cidr column the same way:
CREATE INDEX allowed_networks_net_gist ON allowed_networks USING gist (net inet_ops);
SELECT label FROM allowed_networks WHERE net >>= '10.20.3.4';inet_ops is not the default GiST operator class for these types, so you must name it explicitly. Leaving it out fails with:
ERROR: data type inet has no default operator class for access method "gist"
HINT: You must specify an operator class for the index or define a default operator class for the data type.Arithmetic and sorting
You can add and subtract integers and compute the distance between two addresses:
SELECT '10.0.0.1'::inet + 5, -- 10.0.0.6
'10.0.0.10'::inet - '10.0.0.1'::inet, -- 9
'192.168.1.5'::inet & '255.255.255.0', -- 192.168.1.0
inet '192.168.0.1' + 256; -- 192.168.1.1Overflowing the address space raises ERROR: result is out of range ('255.255.255.255'::inet + 1).
Sorting is numeric, not alphabetical, which is another advantage over text. ORDER BY ip puts 10.20.0.5 before 192.168.10.22/24, and all IPv4 values sort before IPv6. Note that 192.168.10.22/24 sorts before 192.168.10.21 because addresses with a shorter mask sort first when their network parts are compared.
Migrating from a text column
If you already store addresses as text, convert them in place:
-- Find rows that will not convert
SELECT ip FROM devices_text WHERE NOT pg_input_is_valid(trim(ip), 'inet');
-- Convert once the data is clean
ALTER TABLE devices_text
ALTER COLUMN ip TYPE inet USING trim(ip)::inet;Fix or delete the rows the first query returns before running the ALTER TABLE, because a single bad value aborts the whole conversion. On large tables, the ALTER rewrites the table under an ACCESS EXCLUSIVE lock, so schedule it accordingly.
inet, cidr or macaddr?
- Use
inetfor addresses of clients, servers and devices, including when you want to remember the interface's netmask. - Use
cidrfor anything that is a network: allow lists, routing tables, GeoIP ranges. - Use
macaddrormacaddr8for hardware addresses; they are separate types with their own functions.
Tools like Chat2DB (opens in a new tab) display inet and cidr columns as readable addresses and let you ask the AI assistant for queries such as "list devices outside every allowed network", which it turns into the <<= joins shown above.
Summary
inet stores a host with an optional mask, cidr stores a network whose host bits are zero, and the "Value has bits set to right of mask" error is PostgreSQL enforcing that rule. Use network() or a cast to cidr to normalize values, prefer explicit masks over classful shorthand, use <<= and >>= for subnet checks, and back them with a GiST index using inet_ops. With those pieces, IP questions that are painful with text columns become one indexed query.
