Mert Özen Her Satırda Daha İleri
Veritabanı PostgreSQL — #03

PostgreSQL Data Type'ları: text, numeric, timestamptz, uuid, IDENTITY, Enum, Array ve Range

Mert Özen 14 Kas 2026 17 dk 2 görüntülenme
PostgreSQL Data Type'ları: text, numeric, timestamptz, uuid, IDENTITY, Enum, Array ve Range

timestamptz saat dilimi saklamaz, değeri UTC'ye çevirip bir zaman anı olarak tutar ve oturumun TimeZone ayarına göre gösterir; Bookstore'daki her an bu yüzden timestamptz. Gerçek çıktılarla text ve varchar(n) farkı, numeric ile kuruş hatası, serial yerine IDENTITY, uuidv7'nin B-tree index'te %90 doluluğu, enum'un silinemeyen değerleri, array ve daterange, ICU ile Türkçe İ/ı ve sıralama, nondeterministic collation ve casefold().

Sürüm notu: Bu yazıdaki örnekler PostgreSQL 18.7 (resmî postgres:18 Docker image'ı, ICU (International Components for Unicode; dil kurallarını içeren Unicode kütüphanesi) destekli) üzerinde, 02'de kurduğumuz Bookstore lab'ında çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı), siparişin oluşturulma anını saklıyor. Ekipte iki görüş var: biri timestamp diyor, diğeri timestamptz. İkisini yan yana koyup aynı değeri yazdık; oturumun saat dilimi İstanbul. Sonra oturumun saat dilimini önce UTC'ye (Eşgüdümlü Evrensel Zaman), sonra New York'a çevirip aynı satırı okuduk:

CREATE TABLE tz_demo (id int, created_ts timestamp, created_tstz timestamptz);
SET TimeZone = 'Europe/Istanbul';
INSERT INTO tz_demo VALUES (1, '2026-11-14 10:00', '2026-11-14 10:00');

SET TimeZone = 'UTC';
SELECT created_ts, created_tstz FROM tz_demo;
SET TimeZone = 'America/New_York';
SELECT created_ts, created_tstz FROM tz_demo;
SELECT pg_column_size(created_ts) AS ts_bytes, pg_column_size(created_tstz) AS tstz_bytes FROM tz_demo;

Hangi kolonun değeri değişir? timestamptz "with time zone" olduğuna göre İstanbul bilgisini de saklıyor mu? İki kolon diskte kaç byte tutar?

     created_ts      |      created_tstz
---------------------+------------------------
 2026-11-14 10:00:00 | 2026-11-14 07:00:00+00

     created_ts      |      created_tstz
---------------------+------------------------
 2026-11-14 10:00:00 | 2026-11-14 02:00:00-05

 ts_bytes | tstz_bytes
----------+------------
        8 |          8

timestamp hep aynı "10:00"ı gösterdi; bir duvar saatinin okuması gibi, hangi şehirde olduğunu bilmiyor. timestamptz ise aynı anı her oturumun kendi saat diliminde gösterdi: UTC'de 07:00, New York'ta 02:00. İki kolon da 8 byte; yani timestamptz saat dilimi saklamıyor. Adı yanıltıcı ama davranışı net: yazarken gelen değeri UTC'ye çevirip bir zaman anı olarak saklıyor, okurken oturumun TimeZone ayarına göre gösteriyor. Bu yazıda Bookstore şemasındaki tiplerin arkasındaki kararlara bakıyoruz: zaman, metin, sayı, kimlik, enum, array, range ve Türkçe karakterlerle collation.

timestamp ve timestamptz: Saklanan Şey Bir Zaman Anı

SQL Server'dan gelen biri için en yakın karşılıklar şöyle: timestamp, datetime2 gibi saat dilimsiz bir tarih-saat; timestamptz ise datetimeoffset'e benziyor ama offset'i saklamıyor, yalnızca anı saklıyor. Bookstore'da siparişin oluşturulma anı, ödemenin alınma anı, kargonun teslim anı gibi "dünyanın her yerinde aynı an olan" her değer timestamptz. Çözüm Desenleri serisinin zaman ve time zone yazısında tartıştığımız ayrım burada da geçerli: an (instant) ile yerel takvim değeri farklı şeyler. Mağazanın "2 Ocak'ta açılış" gibi yerel bir tarihi için date, bir kampanyanın "her gün 09:00'da başlar" gibi yerel saati için time ya da timestamp doğru tip olabilir.

Bir anı belirli bir yerin saatine çevirmek için AT TIME ZONE kullanılır. Sonucun tipine dikkat:

SELECT created_tstz AT TIME ZONE 'Europe/Istanbul' AS istanbul_wall_clock,
       pg_typeof(created_tstz AT TIME ZONE 'Europe/Istanbul')
FROM tz_demo;
 istanbul_wall_clock |          pg_typeof
---------------------+-----------------------------
 2026-11-14 10:00:00 | timestamp without time zone

timestamptz AT TIME ZONE bir timestamp döndürür: "İstanbul'da duvar saati 10:00 gösteriyordu". Tersine, timestamp AT TIME ZONE bir timestamptz döndürür. Raporlarda gün sınırını (Türkiye saatiyle bir günün siparişleri) hesaplarken bu dönüşümü bilinçli yapmak gerekiyor. Sunucunun ve oturumun TimeZone ayarı yalnızca gösterimi ve saat dilimi belirtilmeden gelen metnin nasıl yorumlanacağını etkiler; lab'da varsayılan Etc/UTC. Npgsql'in DateTime ile timestamptz arasındaki kuralları (UTC Kind zorunluluğu) 11. yazının konusu.

Metin: text, varchar(n) ve char(n)

SQL Server'da varchar(50) ile varchar(max) arasında depolama ve plan farkı vardır, bu yüzden uzunluk vermek alışkanlıktır. PostgreSQL'de text, varchar(n) ve char(n) aynı yapıda saklanır. Dokümantasyon, boşlukla doldurulan char(n)'in fazladan yer kaplaması ve uzunluk sınırlı kolona yazarken yapılan kontrolün maliyeti dışında, bunlar arasında performans farkı olmadığını açıkça söylüyor. Yani varchar(13) bir performans kararı değil, bir kısıt. Kısıtların da sürprizleri var:

CREATE TABLE t_len (isbn varchar(13));
INSERT INTO t_len VALUES ('97800000000011');
INSERT INTO t_len VALUES ('9780000000001  ');
SELECT isbn, length(isbn) FROM t_len;

SELECT 'Dune'::char(10) || '|' AS char_concat, octet_length('Dune'::char(10)) AS char_bytes,
       'Dune'::char(10) = 'Dune  ' AS char_equal;
ERROR:  value too long for type character varying(13)
INSERT 0 1
     isbn      | length
---------------+--------
 9780000000001 |     13

 char_concat | char_bytes | char_equal
-------------+------------+------------
 Dune|       |         10 | t

14 haneli ISBN (kitapların uluslararası standart numarası) reddedildi, ama sonunda iki boşluk olan 15 karakterlik değer hata vermeden 13 karaktere kırpıldı: SQL standardı, taşan kısım yalnızca boşluksa kırpmayı öngörüyor. char(10) diskte 10 byte tutuyor ama birleştirmede sondaki boşluklar atılıyor. char(n)'i kullanmak için pek neden yok; Bookstore şemasında yalnızca ülke ve para birimi kodları için (char(2), char(3)) duruyor ve ikisi de her zaman tam uzunlukta.

Gerçek kuralı ifade etmenin daha iyi yolu CHECK ya da domain. Domain, kısıtıyla birlikte yeniden kullanılabilen bir tip:

CREATE DOMAIN isbn13 AS text CHECK (VALUE ~ '^97[89][0-9]{10}$');
SELECT '9780000000001'::isbn13;
SELECT '978-0000000001'::isbn13;
    isbn13
---------------
 9780000000001

ERROR:  value for domain isbn13 violates check constraint "isbn13_check"

Uzunluk sınırını değiştirmenin maliyeti de sınırın yönüne bağlı. 40.000 satırlık bir kopya üzerinde tablonun fiziksel dosya numarasını (pg_relation_filenode) izledik: varchar(13)'ü varchar(20)'ye ya da text'e çevirmek dosyayı değiştirmedi, yalnızca catalog güncellendi; varchar(13)'e geri daraltmak ise tabloyu yeni bir dosyaya baştan yazdı. Büyük bir tabloda daraltma, tablo boyunca süren bir ACCESS EXCLUSIVE lock demek.

Sayılar: numeric, double precision ve Para

SELECT 0.1::double precision + 0.2::double precision = 0.3 AS float_equal,
       0.1::numeric + 0.2::numeric = 0.3 AS numeric_equal;
SELECT sum(price::double precision) AS float_sum, sum(price) AS numeric_sum FROM books;
SELECT 19.905::numeric(10,2) AS rounded, 19.895::double precision::numeric(10,2) AS via_float;
 float_equal | numeric_equal
-------------+---------------
 f           | t

     float_sum     | numeric_sum
-------------------+-------------
 1100346.759999994 |  1100346.76

 rounded | via_float
---------+-----------
   19.91 |     19.90

Para ve miktar için numeric: 40.000 kitabın fiyat toplamı double precision ile kuruşun altında bir hata taşıyor, numeric ile tam. Son satır daha sinsi: 19,895 ikili kayan noktada tam ifade edilemediği için 19,894999... olarak duruyor ve numeric(10,2)'ye çevrilince 19,90'a yuvarlanıyor; doğrudan numeric olsaydı 19,91 olurdu. Fiyat hesabını bir kez float'a çevirmek bu tür kuruş farklarının kaynağı. PostgreSQL'de bir money tipi de var ama gösterimi lc_monetary ayarına bağlı ve tek para birimi varsayıyor; Bookstore TRY (Türk lirası) ve EUR (Euro) tuttuğu için tutarı numeric(12,2), para birimini ayrı bir kolonda saklıyor. numeric'te hassasiyet vermezsen (yalnızca numeric) değer olduğu gibi saklanır, ölçek sınırı olmaz; Entity Framework Core'un (EF Core) hassasiyet verilmemiş bir decimal için ürettiği tip 02'de gördüğümüz gibi tam olarak bu.

Kalan iki temel tip kısa: boolean gerçek bir mantıksal tip (true, false, NULL) ve SQL Server'daki bit'in aksine WHERE is_active şeklinde doğrudan koşulda kullanılabilir. bytea binary veri içindir, varbinary(max)'in karşılığı; büyük değerleri PostgreSQL TOAST (büyük değerleri sıkıştırıp ayrı bir tabloya taşıyan mekanizma) ile satırın dışına taşır.

IDENTITY, serial ve uuidv7()

Eski PostgreSQL projelerinde serial görürsün. Gerçek bir tip değil; arkada bir sequence oluşturup kolona DEFAULT nextval(...) koyan bir kısaltma. Sorunu da buradan geliyor:

CREATE TABLE wishlists_serial (wishlist_id serial PRIMARY KEY, customer_id int NOT NULL);
INSERT INTO wishlists_serial (wishlist_id, customer_id) VALUES (1, 42);
INSERT INTO wishlists_serial (customer_id) VALUES (43);
INSERT 0 1
ERROR:  duplicate key value violates unique constraint "wishlists_serial_pkey"
DETAIL:  Key (wishlist_id)=(1) already exists.

Biri id'yi elle verdi, sequence bundan habersiz kaldı ve bir sonraki normal insert çakıştı. GENERATED ALWAYS AS IDENTITY elle değer verilmesini 02'de gördüğümüz hatayla engelliyor; ayrıca SQL standardının sözdizimi, sequence yetkileri tabloyla birlikte yönetiliyor ve CREATE TABLE ... (LIKE ...) gibi kopyalamalarda ayrı bir nesne olarak sorun çıkarmıyor. Yeni kodda serial kullanmak için neden yok. GENERATED BY DEFAULT ise elle değere izin verir; EF Core'un Npgsql provider'ı varsayılan olarak bunu üretiyor.

Kimlik olarak uuid gerekiyorsa (istemcide üretilen id, birden fazla kaynaktan birleşen veri, tahmin edilemeyen URL) PostgreSQL 18 iki fonksiyon sunuyor: rastgele gen_random_uuid() (version 4; 18'de uuidv4() adıyla da var) ve yeni uuidv7(). Version 7'nin ilk 48 biti milisaniye cinsinden zaman, yani değerler oluşturulma sırasına göre artıyor:

SELECT gen_random_uuid() AS v4, uuidv7() AS v7a, uuidv7() AS v7b;
SELECT uuid_extract_version(u) AS version, uuid_extract_timestamp(u) AS created
FROM (SELECT uuidv7() AS u) AS s;
                  v4                  |                 v7a                  |                 v7b
--------------------------------------+--------------------------------------+--------------------------------------
 c99536ab-f970-4a98-891a-861170cbb794 | 01a1d7af-09a1-707f-9599-a219669bcebd | 01a1d7af-09a1-7082-87e4-19278cd1ce9c

 version |          created
---------+----------------------------
       7 | 2026-11-14 01:32:15.137+00

Bu sıralılık B-tree index için önemli. İki tabloya birer milyon sepet ekledik, biri v4, diğeri v7 primary key ile; sonra index'leri pgstattuple extension'ının pgstatindex fonksiyonuyla ölçtük. Extension'ı kurmak ve bu fonksiyonu owner rolüyle çağırabilmek için bir kez postgres olarak CREATE EXTENSION pgstattuple; ve GRANT pg_stat_scan_tables TO bookstore_owner; çalıştırman gerekiyor:

CREATE TABLE carts_v4 (cart_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), customer_id int NOT NULL);
CREATE TABLE carts_v7 (cart_id uuid PRIMARY KEY DEFAULT uuidv7(), customer_id int NOT NULL);
INSERT INTO carts_v4 (customer_id) SELECT g FROM generate_series(1, 1000000) AS g;
INSERT INTO carts_v7 (customer_id) SELECT g FROM generate_series(1, 1000000) AS g;

SELECT 'v4' AS kind, avg_leaf_density, leaf_pages FROM pgstatindex('carts_v4_pkey')
UNION ALL
SELECT 'v7', avg_leaf_density, leaf_pages FROM pgstatindex('carts_v7_pkey');
 kind | avg_leaf_density | leaf_pages
------+------------------+------------
 v4   |            71.49 |       4828
 v7   |            89.98 |       3832

Rastgele v4 değerleri index'in her yerine düşüyor; dolan page ortadan ikiye bölünüyor ve page'ler ortalama %71 dolulukta kalıyor. v7 değerleri hep en sağa ekleniyor, page'ler %90 dolu. v4 rastgele olduğu için onun sayıları her çalıştırmada biraz değişir; ikinci denemede %72,41 ve 4766 page çıktı. Sonuç: aynı veri için v7 index'i %20 daha az page, eklemeler sırasında da 162 MB yerine 149 MB WAL (Write-Ahead Log; değişikliklerin önce yazıldığı log). Bu lab'da index'ler shared buffers'a sığdığı için fark mütevazı; index bellekten büyüdüğünde rastgele ekleme her seferinde farklı bir page'i diskten okutmaya başlar ve fark büyür. Bedeli: v7'den kaydın oluşturulma zamanı okunabiliyor; bunu dışarıya göstermek istemediğin yerlerde dikkat et. SQL Server'da NEWSEQUENTIALID() benzer bir ihtiyaca cevap veriyordu; v7 ise zamana göre sıralı uuid'nin standart hâli; uuid standardının 2024'te yayımlanan güncel sürümünde tanımlandı.

Enum, Lookup Tablosu, CHECK ve Domain

Sipariş durumu gibi sabit bir değer listesi için PostgreSQL'in kendine özgü bir seçeneği var: enum type.

CREATE TYPE order_status AS ENUM ('Pending', 'PaymentPending', 'Shipped', 'Delivered', 'Cancelled');
CREATE TABLE status_demo (order_id int, status order_status);
INSERT INTO status_demo VALUES (1, 'Shipped'), (2, 'Pending'), (3, 'Cancelled');
SELECT order_id, status FROM status_demo ORDER BY status;

INSERT INTO status_demo VALUES (4, 'Returned');
ALTER TYPE order_status ADD VALUE 'Returned' BEFORE 'Cancelled';
ALTER TYPE order_status DROP VALUE 'Returned';
 order_id |  status
----------+-----------
        2 | Pending
        1 | Shipped
        3 | Cancelled

ERROR:  invalid input value for enum order_status: "Returned"
ALTER TYPE
ERROR:  dropping an enum value is not implemented

Enum değeri diskte 4 byte, sıralaması alfabetik değil tanımdaki sıra ve geçersiz değeri reddediyor. Yeni değer eklemek ve bir değerin adını değiştirmek (RENAME VALUE) mümkün, ama değer silmek mümkün değil; değer kaldırmak için tipi yeniden oluşturup kolonları dönüştürmek gerekiyor. Çözüm Desenleri serisinin "Enum mı Lookup Tablosu mu?" yazısındaki ölçütler burada da geçerli: liste kodla birlikte değişiyor ve küçükse enum ya da CHECK; değere bağlı başka bilgiler (görünen ad, çeviri, aktif/pasif) varsa ya da iş birimleri listeyi yönetecekse lookup tablosu. Bookstore şemasında orders.status bilinçli olarak text + CHECK: ORM (nesneleri tablolara eşleyen kütüphane) ve migration tarafında en az sorun çıkaran seçenek bu, ve değer listesini değiştirmek tek bir constraint'i yeniden yazmak demek. PostgreSQL enum'larını Npgsql ve EF Core ile eşlemeyi 11 ve 12'de göreceğiz.

Array ve Range

PostgreSQL'de her tipin bir array'i var. Kitap etiketleri için text[] kolonunu deneyelim:

CREATE TABLE book_tags AS SELECT book_id, title FROM books WHERE book_id <= 6;
ALTER TABLE book_tags ADD COLUMN tags text[] NOT NULL DEFAULT '{}';
UPDATE book_tags SET tags = CASE book_id % 3 WHEN 0 THEN ARRAY['classic', 'bestseller']
                                             WHEN 1 THEN ARRAY['classic']
                                             ELSE ARRAY['fantasy', 'bestseller', 'gift'] END;

SELECT book_id FROM book_tags WHERE 'gift' = ANY (tags) ORDER BY 1;
SELECT book_id FROM book_tags WHERE tags @> ARRAY['classic', 'bestseller'] ORDER BY 1;
SELECT tag, count(*) FROM book_tags, unnest(tags) AS tag GROUP BY tag ORDER BY 2 DESC, 1;
 book_id
---------
       2
       5

 book_id
---------
       3
       6

    tag     | count
------------+-------
 bestseller |     4
 classic    |     4
 fantasy    |     2
 gift       |     2

= ANY "array'de var mı", @> "hepsini içeriyor mu" sorusunu soruyor, unnest array'i satırlara açıyor. Bu operator'ler GIN (Generalized Inverted Index; bir satırdaki çok sayıda değeri ayrı ayrı index'leyen yapı) index ile hızlanır; 06'da göreceğiz. Array index'leri 1'den başlar ve sınır dışı bir index hata değil NULL döner. Array'in doğru yeri, kendi başına anlamı olmayan ve başka bir tabloya referans vermeyen küçük değer listeleri: etiketler, izin verilen formatlar. Bir ilişkiyi array'e koyduğunda ne kaybettiğini aşağıda tuzaklar bölümünde göreceğiz.

Range tipleri ise bir aralığı tek değer olarak tutar: int4range, numrange, daterange, tstzrange. Bookstore'un kampanya dönemleri tam bu:

CREATE TABLE campaigns
(
    campaign_id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name        text NOT NULL,
    category    text NOT NULL,
    discount    numeric(4,2) NOT NULL,
    period      daterange NOT NULL
);
INSERT INTO campaigns (name, category, discount, period) VALUES
    ('Back to School', 'Classic', 0.10, '[2026-09-01,2026-09-15)'),
    ('Black Friday',   'SciFi',   0.25, '[2026-11-27,2026-11-30]'),
    ('Winter Reads',   'Classic', 0.15, '[2026-12-01,2027-01-01)');

SELECT name, period FROM campaigns ORDER BY campaign_id;
SELECT name, discount FROM campaigns WHERE period @> date '2026-11-30';
SELECT name FROM campaigns WHERE period && daterange('2026-11-29', '2026-12-03');
SELECT range_agg(period) AS classic_days FROM campaigns WHERE category = 'Classic';
      name      |         period
----------------+-------------------------
 Back to School | [2026-09-01,2026-09-15)
 Black Friday   | [2026-11-27,2026-12-01)
 Winter Reads   | [2026-12-01,2027-01-01)

     name     | discount
--------------+----------
 Black Friday |     0.25

     name
--------------
 Black Friday
 Winter Reads

                   classic_days
---------------------------------------------------
 {[2026-09-01,2026-09-15),[2026-12-01,2027-01-01)}

Köşeli parantez sınırın dahil, normal parantez hariç olduğunu gösterir. "30 Kasım dahil" diye yazdığımız [2026-11-27,2026-11-30], PostgreSQL tarafından [2026-11-27,2026-12-01) biçimine çevrildi; ayrık değerli range'ler her zaman bu kanonik "başlangıç dahil, bitiş hariç" biçimde saklanır. @> "bu tarihi içeriyor mu", && "kesişiyor mu" sorusunu soruyor; SQL Server'da iki ayrı kolon ve start <= @d AND @d < end koşuluyla yazdığın her şey tek operator. range_agg ise birden fazla aralığı bir multirange'de birleştiriyor. Range'in asıl gücü constraint tarafında: "aynı kategoride çakışan iki kampanya olamaz" kuralını EXCLUDE constraint ile ve PostgreSQL 18'in temporal primary key'iyle 04'te kuracağız.

Collation ve Türkçe: libc, ICU ve builtin

Collation, metnin nasıl sıralanacağını ve karşılaştırılacağını, ayrıca upper/lower gibi fonksiyonların harfleri nasıl çevireceğini belirler. PostgreSQL'de üç provider var: işletim sisteminin C kütüphanesi (libc), ICU ve PostgreSQL'in kendi builtin provider'ı. Lab'daki database 02'de gördüğümüz gibi libc ve en_US.utf8 ile oluşturuldu. Bir de image'da hazır gelen ICU collation'larına bakalım:

SELECT collname, collprovider, colllocale FROM pg_collation
WHERE collname IN ('tr-x-icu', 'und-x-icu', 'pg_c_utf8', 'pg_unicode_fast', 'en_US.utf8');
    collname     | collprovider |   colllocale
-----------------+--------------+-----------------
 pg_c_utf8       | b            | C.UTF-8
 pg_unicode_fast | b            | PG_UNICODE_FAST
 en_US.utf8      | c            |
 und-x-icu       | i            | und
 tr-x-icu        | i            | tr

Türkçenin bilinen sorunu noktalı ve noktasız i. Aynı fonksiyonları varsayılan collation ve tr-x-icu ile çalıştırdık:

SELECT upper('istanbul') AS default_upper,
       upper('istanbul' COLLATE "tr-x-icu") AS tr_upper,
       lower('ISPARTA') AS default_lower,
       lower('ISPARTA' COLLATE "tr-x-icu") AS tr_lower;
 default_upper | tr_upper | default_lower | tr_lower
---------------+----------+---------------+----------
 ISTANBUL      | İSTANBUL | isparta       | ısparta

Varsayılan (İngilizce) kurallarla istanbul büyük harfe "ISTANBUL" olarak çevriliyor, Türkçe kurallarla "İSTANBUL". SQL Temelleri serisinin veri tipleri yazısında SQL Server'daki aynı Türkçe İ tuzağını görmüştük; PostgreSQL'de de cevap, ifadeye ya da kolona doğru collation'ı vermek. Sıralamada da fark var:

CREATE TABLE words (w text);
INSERT INTO words VALUES ('Cam'), ('Çiçek'), ('Dut'), ('Ilık'), ('İnci'), ('Işık'), ('Su'), ('Şeker'), ('Tuz'), ('Zil');

SELECT string_agg(w, ' ' ORDER BY w) AS default_en_us,
       string_agg(w, ' ' ORDER BY w COLLATE "tr-x-icu") AS tr_icu,
       string_agg(w, ' ' ORDER BY w COLLATE "C") AS c_bytes
FROM words;
                 default_en_us                 |                    tr_icu                     |                    c_bytes
-----------------------------------------------+-----------------------------------------------+-----------------------------------------------
 Cam Çiçek Dut Ilık İnci Işık Şeker Su Tuz Zil | Cam Çiçek Dut Ilık Işık İnci Su Şeker Tuz Zil | Cam Dut Ilık Işık Su Tuz Zil Çiçek İnci Şeker

İngilizce kurallar Ş'yi S ile aynı harf sayıp "Şeker"i "Su"dan önce koyuyor ve İ ile I'yı karıştırıyor; Türkçe kurallar alfabedeki sırayı veriyor; C ise byte sırası, Türkçe harfleri en sona atıyor. Türkçe içerikli bir katalogda kitap adı sıralaması için kolona COLLATE "tr-x-icu" vermek ya da database'i baştan ICU ile oluşturmak (CREATE DATABASE ... LOCALE_PROVIDER icu ICU_LOCALE 'tr-TR' TEMPLATE template0; template0 olmadan provider uyuşmazlığı hatası alınır) gerekiyor.

Büyük/küçük harf duyarsız karşılaştırma için PostgreSQL'de SQL Server'daki _CI_ collation'ların karşılığı nondeterministic collation. PostgreSQL 18 ile bu collation'lar LIKE ile de çalışıyor:

CREATE COLLATION tr_ci (provider = icu, locale = 'tr-TR-u-ks-level2', deterministic = false);
CREATE TABLE ci_demo (email text COLLATE tr_ci UNIQUE);
INSERT INTO ci_demo VALUES ('Ada@Example.com');
INSERT INTO ci_demo VALUES ('ada@example.com');
SELECT email FROM ci_demo WHERE email LIKE 'ada@%';
SELECT 'ISTANBUL' = 'istanbul' COLLATE tr_ci AS i_eq, 'İSTANBUL' = 'istanbul' COLLATE tr_ci AS dotted_i_eq;
ERROR:  duplicate key value violates unique constraint "ci_demo_email_key"
DETAIL:  Key (email)=(ada@example.com) already exists.

      email
-----------------
 Ada@Example.com

 i_eq | dotted_i_eq
------+-------------
 f    | t

Unique constraint büyük/küçük harf farkını aynı değer saydı, LIKE eşleşti. Son satır Türkçe kuralların sonucu: "ISTANBUL"un küçüğü "ıstanbul" olduğu için "istanbul"a eşit değil, "İSTANBUL" ise eşit. E-posta gibi dil bağımsız alanlarda bu davranış istenmeyebilir; orada und-u-ks-level2 gibi dil bağımsız bir locale daha uygun. Eskiden aynı iş için citext extension'ı kullanılırdı; PostgreSQL dokümantasyonu artık nondeterministic collation'ları öneriyor, çünkü citext küçük harfe çevirmeyi her zaman database'in LC_CTYPE ayarıyla yapıyor ve Unicode'un özel durumlarını doğru işlemiyor.

PostgreSQL 18'in iki yeniliği de bu konuya dokunuyor. casefold(), karşılaştırma için tasarlanmış tam case folding yapıyor ve lower()'dan farklı olarak bir harfin birden fazla karaktere dönüştüğü durumları da kapsıyor. pg_unicode_fast ve tr-x-icu ile denedik:

SELECT casefold('Straße' COLLATE "pg_unicode_fast") AS casefold_fast,
       lower('Straße' COLLATE "pg_unicode_fast") AS lower_fast,
       casefold('ISPARTA' COLLATE "tr-x-icu") AS casefold_tr;
 casefold_fast | lower_fast | casefold_tr
---------------+------------+-------------
 strasse       | straße     | ısparta

Almanca ß iki harfe açıldı, Türkçe ICU collation'ında ise I harfi ı'ya çevrildi. Yeni builtin PG_UNICODE_FAST collation'ı ise dil kuralı uygulamayan, hızlı ve sürümler arasında değişmeyen bir seçenek; Türkçe sıralama ya da I/ı kuralı istiyorsan ICU gerekiyor.

Üretimde Dikkat

Collation sürümleri: libc ve ICU collation'ları işletim sistemi kütüphanesine bağlı. Image'ı ya da sunucunun işletim sistemini yükselttiğinde sıralama kuralları değişebilir ve metin kolonlarındaki B-tree index'ler sessizce bozulabilir. PostgreSQL bunu fark ettiğinde "collation version mismatch" uyarısı verir; doğru tepki ilgili index'leri REINDEX etmek ve ALTER DATABASE ... REFRESH COLLATION VERSION çalıştırmak. Builtin provider bu bağımlılığı taşımadığı için tam da bu nedenle eklendi.

Tip değişiklikleri: kolon tipini değiştiren ALTER TABLE çoğu durumda tabloyu baştan yazar ve süre boyunca tabloyu kilitler. Genişletmek (varchar(13) → varchar(20) ya da text) ucuz, daraltmak ve tip dönüştürmek pahalı. Büyük tablolarda yeni kolon ekle, veriyi parça parça kopyala, sonra değiştir yaklaşımı gerekir; Veritabanı Performansı ve Scaling serisinde büyük tablolarla güvenli çalışma bu konuyu ele alıyor.

Saat dilimi: veritabanı TimeZone ayarını UTC bırak, dönüşümü sunum katmanında ya da sorgu içinde AT TIME ZONE ile açıkça yap. Raporlama araçları ve psql oturumları farklı saat dilimlerinde çalışabilir; timestamptz sayesinde veri doğru kalır, yalnızca gösterim değişir.

Sık Düşülen Tuzaklar

timestamp kolona yerel saat yazmak: SQL Server'dan taşınan datetime2 kolonları çoğu zaman timestamp olarak gelir. Değerler UTC ise ve hep UTC kalacaksa çalışır, ama bir gün bir servis yerel saat yazarsa, hangi satırın hangi saatte olduğunu ayırt etmenin yolu kalmaz. Taşırken timestamptz'ye çevir.

Array'i ilişki yerine kullanmak: istek listesi için book_ids int[] pratik görünür, ama foreign key tanımlanamaz:

CREATE TABLE wishlists (wishlist_id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
                        book_ids int[] REFERENCES books (book_id));
ERROR:  foreign key constraint "wishlists_book_ids_fkey" cannot be implemented
DETAIL:  Key columns "book_ids" of the referencing table and "book_id" of the referenced table are of incompatible types: integer[] and integer.

Constraint olmadan ARRAY[1, 2, 999999] sorunsuz kaydedildi; olmayan kitap ancak sorguda LEFT JOIN ile boş başlık olarak ortaya çıktı. Silinen bir kitabın id'si de array'lerde sessizce kalır. Satır başına bir kayıt olan ara tablo (wishlist_items) hem foreign key'i hem de "bu kitabı kaç kişi istiyor" sorgusunun index'ini verir.

double precision ile para hesaplamak: yukarıda gördüğümüz kuruş farkı, binlerce siparişin toplamında muhasebeyle tutmayan raporlara dönüşür.

Enum'u sık değişen listeler için seçmek: değer silinemediği için yanlışlıkla eklenen ya da artık kullanılmayan bir değer tipte kalıcı olarak durur.

Küçük Bir Deneme

★☆☆ Lab'da SELECT now(), now() AT TIME ZONE 'Europe/Istanbul', pg_typeof(now()); çalıştır. Ardından SET TimeZone = 'Asia/Tokyo'; deyip tekrarla. Hangi değer değişti, hangisi değişmedi?

★★☆ books tablosunun bir kopyasında title kolonuna COLLATE "tr-x-icu" ver ve ORDER BY title LIMIT 20 sonucunu varsayılan collation ile karşılaştır. Sonra aynı kolona normal bir B-tree index koy ve EXPLAIN ile ORDER BY title COLLATE "C" sorgusunun index'i kullanıp kullanmadığına bak.

★★★ campaigns tablosuna aynı kategori için çakışan bir kampanya ekleyen bir INSERT yaz ve bunu engellemeye çalış: önce bir trigger ile, sonra yalnızca constraint ile. İkinci yolun nasıl yapıldığını bir sonraki yazıda göreceğiz.

Bir sonraki yazıda PostgreSQL'e özgü SQL'e geçiyoruz: RETURNING ve 18'in RETURNING OLD/NEW'u, ON CONFLICT ile upsert, DISTINCT ON, writable CTE (Common Table Expression; WITH ile yazılan, veri değiştirebilen sorgu bloğu), EXCLUDE constraint ve generated column.