<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Extensions · XB Field Notes</title><link>https://www.xbcatm.com/tags/extensions/</link><description>Illustrated field notes on HTTP, DNS, TLS, Caddy, SSH, releases, and recovery.</description><generator>Hugo</generator><language>en-us</language><atom:link href="https://www.xbcatm.com/tags/extensions/index.xml" rel="self" type="application/rss+xml"/><item><title>hstore holds a flexible key-value map in a column</title><link>https://www.xbcatm.com/posts/postgres-hstore-flexible-map/</link><pubDate>Wed, 16 Jun 2027 00:00:00 +0000</pubDate><guid>https://www.xbcatm.com/posts/postgres-hstore-flexible-map/</guid><description>The hstore extension adds a string-to-string map type, handy for flexible attributes without forcing a full schema redesign.</description><content:encoded>&lt;p&gt;When a model keeps sprouting loose attributes, hstore holds them in one row instead of a growing join chain. Every product grows settings nobody predicted — per-tenant flags, integration tokens, display preferences. Modeling each as a column means endless migrations; as a table, joins for trivia. hstore offers the middle path — one column carrying a flat string map — for attributes that are numerous, optional, and individually unimportant.&lt;/p&gt;
&lt;p&gt;The bargain is explicit: you trade type safety and constraint power for agility. Keys and values are text, there are no nested structures, and the database cannot enforce much about the contents. For genuinely loose metadata that tradeoff wins; for data the business reasons about, it loses. Knowing which side each attribute falls on is the whole skill.&lt;/p&gt;
&lt;h2 id="one-type-one-column"&gt;One type, one column&lt;/h2&gt;
&lt;p&gt;Once the extension is enabled, an hstore column stores many key-value pairs in a single cell, simpler than mirroring the shape with extra tables. Add it with CREATE EXTENSION hstore and declare the type. Writes use a natural literal — &lt;code&gt;'color=&amp;gt;blue, size=&amp;gt;large'&lt;/code&gt; — and updates touch single keys with concatenation and deletion operators rather than rewriting the map by hand. The application reads the column as a dictionary in most drivers, so round-tripping needs no special parsing layer.&lt;/p&gt;
&lt;p&gt;Migration-wise this is the cheapest flexibility available: one extension, one column, zero new tables, no backfill — absent keys simply do not exist rather than living as NULLs. When a new attribute appears, no DDL runs; the writer just starts including the key.&lt;/p&gt;
&lt;h2 id="query-with-operators"&gt;Query with operators&lt;/h2&gt;
&lt;p&gt;Use the hstore operators for existence and containment, and back the column with a GIN index so those checks stay fast as the map grows. The &lt;code&gt;?&lt;/code&gt; operator tests key existence, &lt;code&gt;-&amp;gt;&lt;/code&gt; extracts a value as text, and &lt;code&gt;@&amp;gt;&lt;/code&gt; matches whole key-value slices — together they cover existence, projection, and containment without unpacking the map client-side. A GIN index directly supports the existence and containment forms; if a hot query extracts one known key and compares its value, add a separate expression index for that key rather than assuming the generic GIN index can answer it.&lt;/p&gt;
&lt;p&gt;Design queries around whole-key access, not pattern surgery on serialized text. &lt;code&gt;data -&amp;gt; 'color'&lt;/code&gt; uses the structure; &lt;code&gt;data::text LIKE '%color%'&lt;/code&gt; defeats it, ignoring the index and matching substrings that are not keys. Operators respect the map, casts destroy it — check for the cast first in review.&lt;/p&gt;
&lt;h2 id="prefer-jsonb-for-rich-rows"&gt;Prefer JSONB for rich rows&lt;/h2&gt;
&lt;p&gt;hstore stores only strings, so when keys need real types or stricter references, JSONB often serves richer rows better. Choose hstore for plain key-value maps and JSONB when the values need to be structured. The decision line runs through three questions: do values need numbers, booleans, or nesting (JSONB wins); do you need constraints or foreign keys into the values (neither serves well — normalize instead); is every value genuinely free-form text with uniform access patterns (hstore wins on simplicity and smaller storage).&lt;/p&gt;
&lt;p&gt;Migration between the two is a single cast in either direction, so the choice is reversible. Start with hstore while the shape is flat; promote to JSONB when the first nested value appears, or to real columns when some key becomes load-bearing logic. The ladder is flat map, structured document, normalized schema — climb it as certainty grows.&lt;/p&gt;
&lt;h2 id="when-it-breaks"&gt;When it breaks&lt;/h2&gt;
&lt;p&gt;The classic failure is the load-bearing string: a key that started as decoration becomes critical — pricing tier, access flag, routing rule — while still living as untyped text with no constraints. Typos create parallel realities (&lt;code&gt;gold&lt;/code&gt; vs &lt;code&gt;Gold&lt;/code&gt;), comparisons sort lexicographically instead of numerically, and no constraint can stop either. Promote such keys to typed columns with checks the moment business logic branches on them; the migration is cheap, the ambiguity it removes is not.&lt;/p&gt;
&lt;p&gt;The second failure is unindexed growth: the map accumulates thousands of distinct keys, queries filter by key without a GIN index, and reads degrade into full scans plus per-row unpacking. Add the GIN index before the key space explodes, and evict keys nobody filters on back to logs or blob storage.&lt;/p&gt;
&lt;h2 id="check-it-quickly"&gt;Check it quickly&lt;/h2&gt;
&lt;p&gt;Prove operators and index behavior:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;SELECT &amp;#39;a=&amp;gt;1, b=&amp;gt;2&amp;#39;::hstore -&amp;gt; &amp;#39;a&amp;#39;, &amp;#39;a=&amp;gt;1, b=&amp;gt;2&amp;#39;::hstore ? &amp;#39;c&amp;#39;;
CREATE INDEX ON prefs USING GIN (attrs);
EXPLAIN SELECT * FROM prefs WHERE attrs @&amp;gt; &amp;#39;theme=&amp;gt;dark&amp;#39;;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The first exercises extraction and existence. The containment query should use the GIN index when its selectivity justifies it; a small table may correctly choose a sequential scan, so compare the plan after loading representative data.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/hstore.html"&gt;PostgreSQL hstore&lt;/a&gt;, Official extension reference for the hstore type, checked 2026-06-01.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/datatype-json.html"&gt;PostgreSQL JSON types&lt;/a&gt;, documents json and jsonb types and operators, checked 2026-09-05.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item></channel></rss>