Israeli Postgres Toolkit
Best practices, patterns, and scripts for building PostgreSQL databases tailored to Israeli applications. Covers Hebrew text handling, shekel currency, Israeli timezones, Supabase integration, and common Israeli data types.
Assumes PostgreSQL 13+ (the patterns use gen_random_uuid() from core, non-deterministic ICU collations need 12+, and generated columns need 12+). Current stable is PostgreSQL 18.
Instructions
Follow this workflow when setting up or reviewing a PostgreSQL database for an Israeli app:
- Verify encoding and timezone first. Run
SHOW server_encoding;(must beUTF8, neverSQL_ASCIIorLATIN1) andSHOW timezone;. Set the database timezone withALTER DATABASE your_db SET timezone = 'Asia/Jerusalem';. Getting these wrong corrupts Hebrew and offsets every timestamp, and fixing it later means a data migration. - Pick the collation strategy. Decide per column whether you need Hebrew display ordering (a non-deterministic ICU collation built from locale
he-IL) or byte-exact prefix search (the default deterministic collation). A non-deterministic collation supportsUNIQUEconstraints andbtreeindexes. From PostgreSQL 18 it also supportsLIKE, butILIKEand regular expressions still error, andLIKEcannot use a btree prefix index. See the operator matrix below before choosing. - Choose the search approach. For exact and prefix matching use
btree. For fuzzy/typo-tolerant Hebrew search usepg_trgm. For multi-field ranked search use full-text search with thesimpleconfiguration (see "Full-Text Search with Hebrew" below). For Hebrew matching that ignores nikud, maqaf and gershayim use thenormalize_hebrew()function shown below, applied to BOTH the column and the query;unaccentonly strips Latin diacritics, not Hebrew nikud. - Apply Israeli data-type constraints. Use the
CHECKconstraints and helper functions fromscripts/israeli-data-types.sql(teudat zehut, phone, postal code, business number, IBAN) and callvalidate_teudat_zehut()for the ID check digit rather than reimplementing it in application code.
Hebrew Text Indexing
ICU Collation for Hebrew
PostgreSQL supports ICU collations for proper Hebrew text sorting. Always create a Hebrew collation for columns that store Hebrew text:
-- Create Hebrew ICU collation
-- locale is the ICU/BCP-47 tag 'he-IL'. Do NOT pass 'he-IL-x-icu': that is the NAME
-- of PostgreSQL's pre-created collation, and '-x-icu' is a private-use subtag ICU
-- silently ignores. (Check with: SELECT collname, colllocale FROM pg_collation;)
CREATE COLLATION IF NOT EXISTS hebrew_icu (
provider = icu,
locale = 'he-IL',
deterministic = false
);
-- Use on columns
CREATE TABLE products (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name_he text COLLATE hebrew_icu NOT NULL,
name_en text NOT NULL
);
-- Or apply in queries
SELECT * FROM products ORDER BY name_he COLLATE hebrew_icu;
Important: what a non-deterministic collation supports changed in PostgreSQL 18, which allowed LIKE and the text-position functions that "used to generate an error" (PG 18 release notes). Verified on PostgreSQL 18.6:
| Operation on a non-deterministic column | PG 13-17 | PG 18+ |
|---|---|---|
UNIQUE constraint, btree index |
Works (no B-tree deduplication) | Same |
LIKE |
ERROR: nondeterministic collations are not supported for LIKE |
Works, but the planner can only use an index as a filter, never as a prefix range scan |
ILIKE |
Error | Still errors |
Regular expressions (~) |
Error | Still errors |
pg_trgm (%, <%) |
Works | Works |
So the advice is unchanged even though the reason is not: keep a deterministic column (or a pg_trgm GIN index) for prefix and fuzzy search, and apply the ICU collation for display ordering and linguistic equality. PostgreSQL 18 also requires a primary/foreign key pair to use either deterministic collations or the same non-deterministic one, which can surface as a pg_upgrade or pg_restore failure on an older schema.
Trigram Fuzzy Search for Hebrew
The pg_trgm extension works well for fuzzy Hebrew search, allowing users to find results even with minor typos:
-- Enable trigram extension
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Create GIN trigram index on Hebrew columns
CREATE INDEX idx_products_name_he_trgm
ON products USING gin (name_he gin_trgm_ops);
-- Fuzzy search query
SELECT name_he, similarity(name_he, 'חשבונ') AS sim
FROM products
WHERE name_he % 'חשבונ'
ORDER BY sim DESC
LIMIT 10;
-- Adjust similarity threshold (default is 0.3)
SET pg_trgm.similarity_threshold = 0.2;
Use <% (word_similarity), not %, when the query is short and the column is long. similarity() compares whole strings, so the score falls as the column grows even though the search term is literally present, while word_similarity() does not. Reproducible on PostgreSQL 18.6 (this body is 104 characters and contains והחשבונית):
\set body 'קיבלנו אתמול מהספק שלנו את והחשבונית עבור ההזמנה האחרונה של החודש שעבר ואנחנו ממתינים לאישור סופי מהמנהל'
SELECT similarity(:'body', 'חשבונית'), -- 0.067 -> :'body' % 'חשבונית' is FALSE
word_similarity('חשבונית', :'body'); -- 0.750 -> 'חשבונית' <% :'body' is TRUE
The crossover depends on length, not on the presence of the word: similarity('והחשבונית נשלחה', 'חשבונית') is 0.333 on the same server and clears the default 0.3 threshold, so % looks fine in testing on short rows and then silently stops matching once real body text is loaded. That is the trap. Rule of thumb: % for short columns (names, titles), <% for body and description columns, <<% (strict_word_similarity) when you want whole-word boundaries. One gin_trgm_ops index serves all three.
Full-Text Search with Hebrew
PostgreSQL's built-in full-text search uses the simple configuration for Hebrew (since there is no dedicated Hebrew dictionary). For better results, combine with pg_trgm:
-- Add search vector column
ALTER TABLE products ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
-- normalize_hebrew() on the Hebrew columns (see "Hebrew Search Normalization"
-- below). Without it, stored nikud and gershayim silently block matches.
setweight(to_tsvector('simple', normalize_hebrew(coalesce(name_he, ''))), 'A') ||
setweight(to_tsvector('simple', normalize_hebrew(coalesce(description_he, ''))), 'B') ||
setweight(to_tsvector('english', coalesce(name_en, '')), 'A') ||
setweight(to_tsvector('english', coalesce(description_en, '')), 'B')
) STORED;
-- Create GIN index
CREATE INDEX idx_products_search ON products USING gin (search_vector);
-- Search query (handles both Hebrew and English).
-- The vector stems the English columns ('invoice' is stored as the lexeme 'invoic'),
-- so a 'simple'-only query can never match them. OR both configurations together.
SELECT * FROM products
WHERE search_vector @@ (plainto_tsquery('simple', normalize_hebrew($1))
|| plainto_tsquery('english', $1))
ORDER BY ts_rank(search_vector,
plainto_tsquery('simple', normalize_hebrew($1))
|| plainto_tsquery('english', $1)) DESC;
simple does no stemming, and Hebrew attaches its prepositions to the word. חשבונית, בחשבונית and והחשבונית become three unrelated lexemes, so FTS alone silently misses most inflected hits. Measured on PostgreSQL 18.6, plainto_tsquery('simple','חשבונית') matched 1 of 3 rows containing the word. Do not rely on FTS alone for Hebrew recall, pair every FTS branch with a <% trigram branch over the same columns (see "Trigram Fuzzy Search" above). scripts/hebrew-search-setup.sql ships search_hebrew() wired this way.
Hebrew Search Normalization (Nikud, Maqaf, Gershayim)
Three separate Hebrew writing conventions each break search, and all three must be normalized on both the stored side and the query side or the two never meet.
1. Nikud. Users will not type vowel points, so "שָׁלוֹם" must still match "שלום". The unaccent extension does NOT strip Hebrew nikud. Its default rule file covers Latin/Greek combining marks (U+0300-U+0362) and contains no entry for the Hebrew points block, so unaccent('שָׁלוֹם') returns the string unchanged.
2. Maqaf. Do NOT strip the whole U+0591-U+05C7 range to remove nikud. That block also holds U+05BE MAQAF (Unicode category Pd, a dash) and U+05C0 PASEQ / U+05C3 SOF PASUQ (Po). Those are word separators. Deleting them concatenates words, so תל־אביב indexes as the single lexeme תלאביב and a search for תל אביב can never match it. Strip only the combining marks (Mn), and turn the separators into spaces.
3. Gershayim. The default FTS parser classifies gershayim (U+05F4) as a blank, so בע״מ tokenizes as two lexemes בע and מ, while the user searching for that company types בעמ. Verified with ts_debug('simple','צה״ל'), which returns צה (word), ״ (blank), ל (word). Every Israeli company name ends in בע״מ, and צה״ל / ד״ר / ח״כ / ש״ח behave the same way, so this silently breaks a large share of real Hebrew queries. Remove geresh and gershayim (and the ASCII ' and " that real input substitutes for them) from both sides.
-- Combining marks only (Mn). NOT the whole U+0591-U+05C7 block.
CREATE FUNCTION strip_nikud(text) RETURNS text
AS $$ SELECT regexp_replace($1, '[֑-ֽֿׁ-ׂׄ-ׇׅ]', '', 'g') $$
LANGUAGE sql IMMUTABLE;
-- Full search-side normalization. IMMUTABLE so it can back a generated column
-- and an expression index.
CREATE FUNCTION normalize_hebrew(text) RETURNS text
AS $$
SELECT regexp_replace(
regexp_replace(
regexp_replace($1, '[֑-ֽֿׁ-ׂׄ-ׇׅ]', '', 'g'), -- nikud + cantillation
'[־׀׃׆]', ' ', 'g'), -- maqaf and friends -> space
'[׳״''"]', '', 'g') -- geresh / gershayim
$$
LANGUAGE sql IMMUTABLE;
-- Verify all three behaviours (each returns true):
SELECT strip_nikud('שָׁלוֹם') = 'שלום'; -- unaccent() would return false
SELECT normalize_hebrew('תל־אביב יפו') = 'תל אביב יפו'; -- maqaf became a space
SELECT normalize_hebrew('בע״מ') = 'בעמ'; -- gershayim removed
-- Use it in the search vector so stored nikud/gershayim do not block matches
ALTER TABLE products ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('simple', normalize_hebrew(coalesce(name_he, '')))
) STORED;
-- Normalize the query exactly the same way
SELECT * FROM products
WHERE search_vector @@ plainto_tsquery('simple', normalize_hebrew($1));
Note: unaccent is still useful for Latin diacritics in your English columns, just not for Hebrew. If you do use unaccent() (which is STABLE), wrap it in an IMMUTABLE f_unaccent(text) that calls unaccent('unaccent', $1) before putting it in a generated column or expression index.
Two indexing rules that follow from this, and that are easy to get wrong:
- Index the same expression you query. A
gin_trgm_opsindex onbody_hedoes not serve a predicate written againstnormalize_hebrew(body_he)orcoalesce(body_he,''). Either match the bare column or build the index over the identical expression, otherwise the predicate degrades to a sequential scan while looking indexed. - Each trigram operator has its own threshold GUC, with deliberately different defaults:
%readspg_trgm.similarity_threshold(0.3),<%readspg_trgm.word_similarity_threshold(0.6),<<%readspg_trgm.strict_word_similarity_threshold(0.5). Setting only the first leaves a<%branch gated at 0.6 no matter what you thought you configured. Do not paper over that by feeding one value into all three either:word_similarityscores the best-matching word extent rather than the whole string, so it runs much higher and a 0.2-0.3 setting there returns near-unrelated rows.
Currency Handling (NIS / Shekel)
Column Types for NIS Amounts
Always use numeric for monetary values. Never use float or double precision, as they cause rounding errors:
-- Correct: numeric with fixed precision
CREATE TABLE invoices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
amount_nis numeric(12, 2) NOT NULL CHECK (amount_nis >= 0),
vat_amount numeric(12, 2) NOT NULL DEFAULT 0,
total_nis numeric(12, 2) GENERATED ALWAYS AS (amount_nis + vat_amount) STORED,
currency text NOT NULL DEFAULT 'ILS' CHECK (currency IN ('ILS', 'USD', 'EUR'))
);
-- Wrong: never do this for money
-- amount float NOT NULL -- ROUNDING ERRORS!
VAT Calculation
Israeli VAT (Ma'am) is 18% (raised from 17% on 2025-01-01). Store the rate in a config table so it can be updated without a code deploy when the next rate change lands:
-- A SINGLETON row is the wrong shape. The rate changes (17% to 18% on 2025-01-01,
-- and the next change will come), and a singleton silently re-prices every historical
-- invoice the moment you update it. Keep the history and look the rate up as-of the
-- invoice date, the same way the exchange-rate table does.
CREATE TABLE vat_rates (
rate numeric(5, 4) NOT NULL,
effective_from date NOT NULL PRIMARY KEY
);
INSERT INTO vat_rates (rate, effective_from)
VALUES (0.1700, '2013-06-02'), (0.1800, '2025-01-01');
-- Calculate VAT using the rate in force on the invoice's own date
SELECT
i.amount_nis,
round(i.amount_nis * v.rate, 2) AS vat,
round(i.amount_nis * (1 + v.rate), 2) AS total
FROM invoices i
CROSS JOIN LATERAL (
SELECT rate FROM vat_rates
WHERE effective_from <= i.invoice_date
ORDER BY effective_from DESC
LIMIT 1
) v;
Rate also varies by line, not only by date: an export line is zero-rated and some transactions are exempt, so a single per-invoice rate column cannot represent a mixed invoice. Put the rate on the line item once you have more than one rate class.
Formatting NIS Amounts
Use PostgreSQL's to_char for display formatting:
SELECT to_char(amount_nis, 'FM999,999,990.00') || ' ₪' AS formatted_amount
FROM invoices;
-- For application code, format in the app layer:
-- JavaScript: new Intl.NumberFormat('he-IL', { style: 'currency', currency: 'ILS' }).format(amount)
BOI Exchange Rates
When integrating Bank of Israel exchange rates, store them with their effective date:
CREATE TABLE exchange_rates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
currency_code text NOT NULL, -- 'USD', 'EUR', etc.
rate_to_ils numeric(12, 6) NOT NULL, -- How many ILS per 1 unit
effective_date date NOT NULL,
source text NOT NULL DEFAULT 'BOI',
fetched_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (currency_code, effective_date)
);
-- Get latest rate for USD
SELECT rate_to_ils FROM exchange_rates
WHERE currency_code = 'USD'
ORDER BY effective_date DESC
LIMIT 1;
Never look a rate up by exact date. The Bank of Israel publishes no representative rate on Saturdays or Israeli holidays, so WHERE effective_date = $1 returns zero rows for any Shabbat-dated document, and a NOT NULL conversion column then fails the insert. Carry the last published rate forward with an as-of lookup:
-- Rate in force ON a given date (carries Friday's rate through Shabbat)
SELECT rate_to_ils FROM exchange_rates
WHERE currency_code = 'USD' AND effective_date <= $1
ORDER BY effective_date DESC
LIMIT 1;
Timezone Handling (Asia/Jerusalem)
Database Configuration
Always store timestamps with timezone and configure the database for Israel:
-- Set database timezone (do this in your migration or DB config)
ALTER DATABASE your_db SET timezone = 'Asia/Jerusalem';
-- Verify
SHOW timezone; -- Should return 'Asia/Jerusalem'
-- Always use timestamptz, never timestamp without timezone
CREATE TABLE events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title text NOT NULL,
starts_at timestamptz NOT NULL, -- CORRECT
-- starts_at timestamp NOT NULL, -- WRONG: loses timezone info
created_at timestamptz NOT NULL DEFAULT now()
);
DST Transition Handling
Israel observes daylight saving time (IDT, UTC+3 in summer; IST, UTC+2 in winter). The transition dates change yearly. Key gotchas:
-- Check current offset
SELECT now(), now() AT TIME ZONE 'Asia/Jerusalem',
EXTRACT(timezone_hour FROM now()) AS utc_offset;
-- Convert between timezones safely
SELECT starts_at AT TIME ZONE 'Asia/Jerusalem' AS local_time
FROM events;
-- CRITICAL: Friday night Shabbat edge case
-- Shabbat starts at sunset Friday. If scheduling around Shabbat times,
-- do NOT hardcode times. Use a Shabbat times API and store as timestamptz.
-- Find events happening on a specific Israeli date.
-- Do NOT write WHERE (starts_at AT TIME ZONE 'Asia/Jerusalem')::date = '2026-03-14':
-- wrapping the column kills the index on starts_at and forces a sequential scan.
-- Use a half-open range on the bare column instead, computed from the local date.
SELECT * FROM events
WHERE starts_at >= timestamp '2026-03-14 00:00' AT TIME ZONE 'Asia/Jerusalem'
AND starts_at < timestamp '2026-03-15 00:00' AT TIME ZONE 'Asia/Jerusalem';
Scheduling Around Israeli Calendar
When building scheduling features, account for:
- Shabbat (Friday sunset to Saturday nightfall): no notifications/processing
- Jewish holidays: variable dates each year
- Israeli business hours: Sunday through Thursday (Friday is half-day)
-- Check if a timestamp falls on Israeli business hours (Sun-Thu, 9:00-17:00)
CREATE OR REPLACE FUNCTION is_israeli_business_hours(ts timestamptz)
RETURNS boolean AS $$
DECLARE
local_ts timestamp := ts AT TIME ZONE 'Asia/Jerusalem';
dow int := EXTRACT(dow FROM local_ts); -- 0=Sun, 6=Sat
hour int := EXTRACT(hour FROM local_ts);
BEGIN
-- Sunday(0) through Thursday(4), 9:00-17:00
RETURN dow BETWEEN 0 AND 4 AND hour BETWEEN 9 AND 16;
END;
-- STABLE, not IMMUTABLE: AT TIME ZONE on a timestamptz depends on the tz database,
-- so a tzdata update can change the result. Marking it IMMUTABLE would risk wrong
-- cached/indexed values.
$$ LANGUAGE plpgsql STABLE;
Israeli Date Patterns
Hebrew Calendar Integration
For applications that need Hebrew calendar dates alongside Gregorian, store both:
CREATE TABLE appointments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
gregorian_date date NOT NULL,
hebrew_date_display text, -- e.g., "י״ד אדר ב׳ תשפ״ה"
scheduled_at timestamptz NOT NULL
);
-- Hebrew date conversion should be done in the application layer
-- (using libraries like hebcal in JavaScript or pyluach in Python)
-- Store the display string for quick rendering
Israeli Date Display Formats
-- Israeli date format: DD/MM/YYYY (not MM/DD/YYYY)
SELECT to_char(created_at AT TIME ZONE 'Asia/Jerusalem', 'DD/MM/YYYY') AS israeli_date
FROM events;
-- With time
SELECT to_char(
created_at AT TIME ZONE 'Asia/Jerusalem',
'DD/MM/YYYY HH24:MI'
) AS israeli_datetime
FROM events;
Supabase-Specific Patterns
RLS Policies for Multi-Tenant Israeli SaaS
-- Enable RLS
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- Tenant isolation. Read the claim from app_metadata, NOT from the top level and
-- NOT from user_metadata:
-- * Supabase does not put a custom top-level claim in the access token unless you
-- add a custom access token hook, so `auth.jwt() ->> 'tenant_id'` is NULL and the
-- policy fails closed to zero visible rows, which looks like a data bug.
-- * user_metadata is writable by the end user, so reading a tenant id from it is a
-- tenant escape, not a workaround.
-- RESTRICTIVE so a later permissive policy cannot widen it, and TO authenticated so
-- it does not also run for the anon role.
CREATE POLICY tenant_isolation ON invoices
AS RESTRICTIVE
TO authenticated
USING (tenant_id = ((select auth.jwt()) -> 'app_metadata' ->> 'tenant_id')::uuid);
-- Index the column every policy filters on, or each check is a sequential scan.
CREATE INDEX idx_invoices_tenant ON invoices (tenant_id);
-- Admin override (Israeli admin users can see all tenants)
CREATE POLICY admin_access ON invoices
FOR ALL
TO authenticated
USING (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.role = 'admin'
)
);
-- Read-only for accountant role (common in Israeli business apps)
CREATE POLICY accountant_read ON invoices
FOR SELECT
TO authenticated
USING (
tenant_id = ((select auth.jwt()) -> 'app_metadata' ->> 'tenant_id')::uuid
AND EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.role IN ('accountant', 'admin')
)
);
PostgREST Gotchas with Hebrew
When using Supabase's PostgREST API with Hebrew content:
-- Column aliases with Hebrew work but require URL encoding
-- Better approach: use English column names, store Hebrew in values
-- Avoid: columns named with Hebrew characters
-- CREATE TABLE test (שם text); -- DON'T DO THIS
-- Correct: English column names, Hebrew values
CREATE TABLE businesses (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name_he text NOT NULL, -- Hebrew business name
name_en text, -- English business name
business_type text NOT NULL -- English enum values
);
-- PostgREST filter with Hebrew values (URL-encode the value)
-- GET /businesses?name_he=eq.%D7%97%D7%A0%D7%95%D7%AA
Edge Function + DB Connection Pooling
For Supabase Edge Functions connecting to the database:
// In Supabase Edge Functions, always use the pooler connection.
// Direct connection: postgresql://postgres:password@db.xxx.supabase.co:5432/postgres
// Pooled (transaction mode): postgresql://postgres:password@aws-[region].pooler.supabase.com:6543/postgres
// Copy the exact host from the project's Connect dialog; do not hardcode a prefix.
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
// The service role key runs as a Postgres role with the bypassrls attribute, so it
// IGNORES every policy above. Never ship it to a browser, and do tenant filtering
// explicitly in any Edge Function that uses it.
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// For direct SQL queries in Edge Functions, use the pooler
// This avoids connection exhaustion under load
Performance Tuning
Connection Pooling
For Israeli SaaS apps on Supabase, connection pooling is critical:
- Supavisor (Supabase's shared pooler):
aws-[region].pooler.supabase.comon port 6543 for transaction mode, port 5432 for session mode. Supabase writes the host as theaws-[region]placeholder, copy the exact string from your project's Connect dialog rather than hardcoding a prefix. - PgBouncer: available as Supabase's dedicated pooler, and the usual choice when self-hosting.
- Pool size is not a plan constant. It is one setting that Supavisor and PgBouncer both reference, capping the server-side connections a pooler opens to Postgres. What varies by compute tier is a separate "max pooler clients" ceiling on how many clients may connect to a pooler at once, alongside your instance's Postgres
max_connections. Read the current numbers off your own project rather than hardcoding any figure.
Index Strategies for Hebrew Text
-- B-tree index for exact Hebrew matches
CREATE INDEX idx_businesses_name_he ON businesses (name_he);
-- GIN trigram for fuzzy search
CREATE INDEX idx_businesses_name_he_trgm
ON businesses USING gin (name_he gin_trgm_ops);
-- GIN for full-text search
CREATE INDEX idx_businesses_search
ON businesses USING gin (search_vector);
-- Partial index for published Hebrew content
CREATE INDEX idx_published_he ON products (name_he)
WHERE is_published = true;
Partitioning by Israeli Fiscal Year
Israel's fiscal year aligns with the calendar year (January to December). For large transaction tables:
-- Partition invoices by year
CREATE TABLE invoices_partitioned (
id uuid NOT NULL DEFAULT gen_random_uuid(),
amount_nis numeric(12, 2) NOT NULL,
invoice_date date NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
) PARTITION BY RANGE (invoice_date);
-- Create yearly partitions
CREATE TABLE invoices_2024 PARTITION OF invoices_partitioned
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
CREATE TABLE invoices_2025 PARTITION OF invoices_partitioned
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
CREATE TABLE invoices_2026 PARTITION OF invoices_partitioned
FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
Common Israeli Data Types
Teudat Zehut (Israeli ID Number)
-- Store as text (not integer) to preserve leading zeros
-- 9 digits, validated with Luhn-like algorithm
CREATE TABLE customers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
teudat_zehut text UNIQUE CHECK (
teudat_zehut ~ '^\d{9}$'
),
-- Additional identity fields
passport_number text,
tax_id text -- Osek Murshe / Osek Patur number
);
Note: The ~ '^\d{9}$' constraint only checks the format (9 digits), not the check digit. Teudat Zehut uses a Luhn-variant check digit algorithm. This skill ships a ready-made validate_teudat_zehut(text) function in scripts/israeli-data-types.sql, install it and use it in a CHECK constraint or a BEFORE INSERT trigger so invalid IDs are rejected at the database layer:
-- After installing validate_teudat_zehut() from israeli-data-types.sql
ALTER TABLE customers ADD CONSTRAINT chk_teudat_zehut_valid
CHECK (teudat_zehut IS NULL OR validate_teudat_zehut(teudat_zehut));
Israeli Phone Numbers
-- Israeli phone. Keep the DB CHECK PERMISSIVE: a too-strict constraint throws on
-- legitimate numbers (1-700/1-800 service lines, 07X VoIP/non-geographic) and blocks
-- inserts in production. Do exact formatting/normalization in the app layer.
ALTER TABLE customers ADD COLUMN phone text CHECK (
phone ~ '^05\d{8}$' -- Mobile: 05X + 8 digits (10 total)
OR phone ~ '^07\d{8}$' -- VoIP / non-geographic: 07X + 7 digits (10 total)
OR phone ~ '^0[23489]\d{7}$' -- Landline: area code + 7 digits (9 total)
OR phone ~ '^1[78]00\d{6}$' -- Service: 1-700 / 1-800 + 6 digits
OR phone ~ '^\*\d{3,4}$' -- Short codes: *XXX or *XXXX
);
-- Or store in E.164 format for international compatibility
ALTER TABLE customers ADD COLUMN phone_e164 text CHECK (
phone_e164 ~ '^\+972\d{8,9}$'
);
Israeli Address Fields
CREATE TABLE addresses (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
street_he text NOT NULL, -- Hebrew street name
street_en text, -- English transliteration
house_number text NOT NULL, -- Text to handle "12/3" or "12א"
apartment text, -- Apartment/floor
city_he text NOT NULL,
city_en text,
postal_code text CHECK (postal_code ~ '^\d{7}$'), -- Israeli: 7 digits
region text -- 'north', 'center', 'south', 'jerusalem', 'haifa', 'tel-aviv'
);
Examples
Example 1: Bilingual product catalog with fuzzy Hebrew search
User says: "I need a products table that supports typo-tolerant search in Hebrew and English."
Actions:
CREATE EXTENSION IF NOT EXISTS pg_trgm;(andunaccentfor the English columns), then define theIMMUTABLEnormalize_hebrew(text)function for Hebrew.- Create
productswithname_he,name_en,description_he,description_en, plus a generatedsearch_vectorusingto_tsvector('simple', normalize_hebrew(...))for Hebrew columns and'english'for English columns. - Add a GIN index on
search_vectorand GINgin_trgm_opsindexes onname_heandname_en. - Query with
plainto_tsquery('simple', normalize_hebrew($1))for ranked results, OR-ed withplainto_tsquery('english', $1)so the stemmed English columns can match too, and fall back to aname_he % $1trigram match for typo tolerance.
Result: Users find "חשבונית" even if they type "חשבונ" or include nikud, and English queries still work through the same column.
Example 2: Israeli invoice table with VAT and ID constraints
User says: "Create an invoices table that enforces correct VAT math and valid Israeli IDs."
Actions:
- Install
validate_teudat_zehut()fromscripts/israeli-data-types.sql. - Create
invoiceswithsubtotal_nis numeric(12,2),vat_rate numeric(5,4) DEFAULT 0.1800,vat_amount numeric(12,2),total_nis numeric(12,2). - Add
CHECK (vat_amount = round(subtotal_nis * vat_rate, 2))andCHECK (total_nis = subtotal_nis + vat_amount). - Add
customer_teudat_zehut text CHECK (customer_teudat_zehut IS NULL OR validate_teudat_zehut(customer_teudat_zehut)). - Store the VAT rate in the singleton
tax_configtable so a rate change is a data update, not a deploy.
Result: The database itself rejects invoices with wrong VAT arithmetic or malformed Israeli ID numbers.
Bundled Resources
This skill includes helper scripts in the scripts/ directory:
hebrew-search-setup.sql: Sets up Hebrew full-text search with proper collation, trigram indexes, and search functionsisraeli-data-types.sql: Complete CREATE TABLE templates with Israeli-specific columns, constraints, and validations, including thevalidate_teudat_zehut()andformat_israeli_phone()helper functions
And reference documents in references/:
hebrew-collation-guide.md: Detailed ICU collation reference for Hebrew text in PostgreSQLsupabase-israel-patterns.md: Supabase-specific patterns and configurations for Israeli apps
Recommended MCP Servers
These MCP servers from the directory pair well with this skill when an Israeli database needs live external data:
- boi-exchange: Bank of Israel exchange rates, use to populate the
exchange_ratestable on a schedule instead of hardcoding rates. - hebcal: Hebrew/Jewish calendar dates, use to fill
hebrew_date_displaycolumns or to drive Shabbat/holiday-aware scheduling logic that would otherwise need hardcoded dates.
Reference Links
| Source | URL | What to Check |
|---|---|---|
| PostgreSQL Collation Support | https://www.postgresql.org/docs/current/collation.html | ICU collations, deterministic vs non-deterministic |
| PostgreSQL 18 release notes | https://www.postgresql.org/docs/release/18.0/ | LIKE now allowed with nondeterministic collations; PK/FK collation rule |
| PostgreSQL pg_trgm | https://www.postgresql.org/docs/current/pgtrgm.html | Trigram operators, similarity threshold, GIN indexes |
| PostgreSQL unaccent | https://www.postgresql.org/docs/current/unaccent.html | Latin diacritic stripping (NOT Hebrew nikud), IMMUTABLE wrapper |
| Supabase Row Level Security | https://supabase.com/docs/guides/database/postgres/row-level-security | RLS policies, auth.jwt(), multi-tenant patterns |
| Bank of Israel exchange rates | https://www.boi.org.il/en/economic-roles/financial-markets/exchange-rates/ | Representative rates for the exchange_rates table |
| ICU Locale identifiers | https://www.postgresql.org/docs/current/collation.html#ICU-CUSTOM-COLLATIONS | BCP-47 locale syntax for CREATE COLLATION (he-IL, he-IL-u-ks-level1) |
Troubleshooting
Error: "nondeterministic collations are not supported for ILIKE" (or "... for regular expressions")
Cause: A column declared with the non-deterministic hebrew_icu collation is being used with ILIKE or a regex operator. Both still error on PostgreSQL 18. Plain LIKE on the same column raised the equivalent error up to PostgreSQL 17 and was allowed in 18.
Solution: Do the match against a deterministic expression, WHERE name_he COLLATE "default" ILIKE 'שלום%', or keep a separate deterministic column (or a pg_trgm GIN index) for prefix and fuzzy search. Note that even where LIKE is now accepted, the planner uses the index only as a filter, so a pg_trgm index remains the faster route for prefix search. UNIQUE constraints and plain btree indexes are fine on a non-deterministic column, they simply lose B-tree deduplication.
Error: "generation expression is not immutable" when adding a search_vector column
Cause: unaccent() is STABLE, not IMMUTABLE, so it cannot be used directly inside a GENERATED ALWAYS AS ... STORED expression.
Solution: Create an IMMUTABLE SQL wrapper, CREATE FUNCTION f_unaccent(text) RETURNS text AS $$ SELECT unaccent('unaccent', $1) $$ LANGUAGE sql IMMUTABLE;, and use f_unaccent(...) in the generated column and any expression index.
Gotchas
- Hebrew text in PostgreSQL requires UTF-8 encoding. Databases created with SQL_ASCII or LATIN1 encoding will corrupt Hebrew characters. Always verify encoding with SHOW server_encoding.
- Hebrew collation in PostgreSQL (he_IL.UTF-8) sorts differently than English. Agents may apply default collation that sorts Hebrew text incorrectly in ORDER BY queries.
- PostgreSQL has no Hebrew full-text search dictionary, so
simpleIS the correct configuration for Hebrew tsvector columns. Agents often wrongly reach for'english'(which strips English stopwords and stems Latin words, doing nothing useful for Hebrew) or invent a nonexistent'hebrew'config (which errors out). Use'simple'for Hebrew columns and combine it withpg_trgmandnormalize_hebrew()for better recall (unaccentdoes not help Hebrew recall, it leaves nikud intact). Note'simple'does no stemming, so Hebrew prefixes (ו/ב/כ/ל/ה/מ/ש) and plural/construct forms become distinct lexemes, measured recall was 1 of 3 rows; always pair FTS with a<%trigram branch over the SAME columns, including body columns, or inflected hits are lost. - The default FTS parser treats gershayim (U+05F4) as a blank, so
בע״מbecomes the two lexemesבעandמwhile users typeבעמ. Agents store the typographically correct character (which is right) and then never normalize it for search (which breaks every acronym: בע״מ, צה״ל, ד״ר, ח״כ, ש״ח). Strip geresh and gershayim on both the stored and query side. - Do not strip the whole U+0591-U+05C7 range to remove nikud. It contains U+05BE MAQAF, a word-separating dash, so
תל־אביבcollapses into the single lexemeתלאביבandתל אביבstops matching. Strip the combining marks only and map the separators to spaces. - Israeli date columns should store dates as DATE or TIMESTAMPTZ (with timezone Asia/Jerusalem), not as TEXT in DD/MM/YYYY format. Agents may create text columns for dates, breaking comparisons and sorting.