SQL Server’da tablo tasarımı, verinin hangi sütunlarda, hangi veri tipleriyle ve hangi kurallarla (kısıtlarla) saklanacağını T-SQL’in CREATE TABLE, ALTER TABLE ve DROP TABLE komutlarıyla tanımlamaktır. İyi tasarlanmış bir tablo yanlış veriyi daha girişte reddeder; kötü tasarlanmış bir tabloyu ise sonradan hiçbir indeks kurtaramaz.
Bu yazı, 2020’de ayrı ayrı yayımladığım “SQL de Kodla Tablo Oluşturma” ve “SQL de Alter Tablo” notlarının birleştirilmiş ve düzeltilmiş hâlidir. Eski örneklerde SQL Server’da çalışmayan sözdizimleri vardı; aşağıda hem doğrusunu hem nedenini yazdım.
SQL komut grupları: DDL, DML, DCL ve TCL
Eski notta DDL ve DML ayrımı vardı. Tabloyu dört grupla düşünmek daha kullanışlı:
| Grup | Açılımı | Komutlar | Ne yapar |
|---|---|---|---|
| DDL | Data Definition Language | CREATE, ALTER, DROP, TRUNCATE TABLE |
Nesnelerin yapısını tanımlar |
| DML | Data Manipulation Language | SELECT, INSERT, UPDATE, DELETE, MERGE |
Verinin kendisini okur ve değiştirir |
| DCL | Data Control Language | GRANT, REVOKE, DENY |
Yetkileri yönetir |
| TCL | Transaction Control Language | BEGIN TRAN, COMMIT, ROLLBACK |
İşlemleri (transaction) yönetir |
SQL Server’da DDL komutları da işlem içinde çalışır; BEGIN TRAN içinde yapılan bir DROP TABLE henüz COMMIT edilmediyse ROLLBACK ile geri alınabilir.
Eski örnekteki hatalar
2020 notundaki örneklerde şu sorunlar vardı:
CREATE <tablo adı>yazılmıştı; doğrusuCREATE TABLE <şema>.<tablo>.- Sütun tanımları arasında virgüller eksikti ve parantez yanlış yerde kapanıyordu.
FLOAT(x;y),LOGICALveGRAPHIC(n)SQL Server veri tipi değildir.float(n)tek parametre alır, mantıksal değer içinbitkullanılır.TIMESTAMPSQL Server’da tarih değil,rowversiontipinin eski adıdır; satır her güncellendiğinde değişen bir sürüm numarası tutar.ALTER TABLE Musteri ADD tel varchar(20) NOT NULLkomutu tablo doluysa hata verir; aşağıda nedenini anlatıyorum.
Veri tipi nasıl seçilir?
Veri tipi, hem doğruluğu hem de depolama ve performansı belirler. Pratikte kullandığım tablo:
| İhtiyaç | Önerilen tip | Kaçının | Neden |
|---|---|---|---|
| Kimlik (ID) | int, çok büyük tablolarda bigint |
numeric(18,0), uniqueidentifier (kümelenmiş anahtar olarak) |
Dar, sıralı anahtar |
| Para, fiyat, miktar | decimal(p,s) |
float, real |
float yaklaşık değer saklar, kuruş kaydırır |
| Tarih ve saat | datetime2(n) |
datetime, timestamp |
Daha geniş aralık ve ayarlanabilir hassasiyet |
| Saat dilimiyle zaman | datetimeoffset |
Metin olarak saklamak | Ofset bilgisini korur |
| Yalnızca tarih | date |
datetime |
3 bayt, saat karmaşası yok |
| Türkçe ya da çok dilli metin | nvarchar(n) |
text, ntext |
text/ntext eski tiplerdir |
| ASCII kod, e-posta, barkod | varchar(n) |
nvarchar(max) |
Gereksiz alan ve bellek |
| Evet/Hayır | bit |
char(1) ile ‘E’/‘H’ |
Tek anlamlı, küçük |
nvarchar mı varchar mı?
char ve varchar içindeki n, Microsoft belgesinin özellikle vurguladığı gibi karakter sayısı değil bayt sayısıdır. Türkçe harmanlamada (ör. Turkish_100_CI_AS) varchar Türkçe karakterleri saklar ama Arapça ya da Yunanca bir müşteri adı gelirse karakterler ? olur. SQL Server 2019 ile gelen UTF-8 harmanlamalarında varchar tüm Unicode karakterlerini saklayabilir. Daha eski sürümlerde ya da karma ortamlarda nvarchar en güvenli seçim. Unicode sabitlerini N'İstanbul' biçiminde, N önekiyle yazmayı unutmayın.
datetime2 ve decimal
datetime2 0001–9999 yılları arasını 100 nanosaniye doğrulukla saklar; hassasiyeti datetime2(0) ile saniyeye indirip yerden kazanabilirsiniz. Microsoft yeni geliştirmelerde datetime yerine date, time, datetime2 ve datetimeoffset kullanılmasını önerir. Para için decimal(12,2) gibi kesin bir tip seçin; float ile toplanan fiyatlar raporlarda kuruş farkı üretir.
NULL politikası
Varsayılanınız NOT NULL olsun. NULL yalnızca “bilinmiyor” ya da “uygulanamaz” anlamı taşıyan alanlarda kabul edilmeli. Her sütunda NULL ya da NOT NULL ifadesini açıkça yazın: oturum ayarları varsayılan boş geçilebilirliği değiştirebilir. ALTER COLUMN ile tip değiştirirken belirtmezseniz sütun boş geçilebilir hâle gelir.
Kısıtlar açısından iki tuzak var. CHECK kısıtı yalnızca FALSE sonucu reddeder; NULL değer UNKNOWN sonucunu verdiği için kontrolden geçer. UNIQUE kısıtı ise SQL Server’da sütun başına yalnızca bir NULL kabul eder. Birden çok boş değere izin verip dolu değerlerde tekillik istiyorsanız aşağıdaki örnekteki gibi filtreli benzersiz indeks kullanın.
Adlandırma kuralları
- Tabloları bir şema altında toplayın (
satis.Musteri);dboher şeyin çöplüğü olmasın. - Tanımlayıcılarda Türkçe karakter ve boşluk kullanmayın. Türkçe harmanlamada büyük/küçük harf duyarsızlığı i/I için beklendiği gibi çalışmaz;
musteriidileMusteriIDeşleşmeyebilir. Tutarlı PascalCase yazın. - Kısıtlara açık ad verin:
PK_Tablo,FK_Cocuk_Ebeveyn,UQ_Tablo_Sutun,CK_Tablo_Sutun,DF_Tablo_Sutun. Sistemin ürettiğiPK__Musteri__3214EC07...gibi adlar hata mesajlarını okumayı ve kısıtı sonradan kaldırmayı zorlaştırır.
Çalışır örnek şema: Müşteri–Sipariş
Aşağıdaki betik SQL Server 2016 ve sonrasında çalışır. Müşteri, ürün, sipariş ve sipariş kalemi tablolarıyla birincil anahtar (PRIMARY KEY), IDENTITY, yabancı anahtar (FOREIGN KEY), UNIQUE, CHECK ve DEFAULT kısıtlarını birlikte gösterir.
IF DB_ID(N'MagazaDemo') IS NULL
CREATE DATABASE MagazaDemo;
GO
USE MagazaDemo;
GO
IF SCHEMA_ID(N'satis') IS NULL
EXEC (N'CREATE SCHEMA satis AUTHORIZATION dbo;');
GO
CREATE TABLE satis.Musteri
(
MusteriId int IDENTITY(1, 1) NOT NULL,
MusteriKodu varchar(10) NOT NULL,
AdSoyad nvarchar(100) NOT NULL,
Eposta varchar(254) NULL,
Telefon varchar(20) NULL,
Il nvarchar(50) NULL,
UlkeKodu char(2) NOT NULL CONSTRAINT DF_Musteri_UlkeKodu DEFAULT ('TR'),
KayitTarihi datetime2(0) NOT NULL CONSTRAINT DF_Musteri_KayitTarihi DEFAULT (SYSDATETIME()),
AktifMi bit NOT NULL CONSTRAINT DF_Musteri_AktifMi DEFAULT (1),
CONSTRAINT PK_Musteri PRIMARY KEY CLUSTERED (MusteriId),
CONSTRAINT UQ_Musteri_MusteriKodu UNIQUE (MusteriKodu),
CONSTRAINT CK_Musteri_UlkeKodu CHECK (LEN(UlkeKodu) = 2)
);
GO
-- Birden çok NULL'a izin veren, dolu değerlerde tekil e-posta
CREATE UNIQUE NONCLUSTERED INDEX UX_Musteri_Eposta
ON satis.Musteri (Eposta)
WHERE Eposta IS NOT NULL;
GO
CREATE TABLE satis.Urun
(
UrunId int IDENTITY(1, 1) NOT NULL,
Barkod varchar(20) NOT NULL,
UrunAdi nvarchar(200) NOT NULL,
BirimFiyat decimal(12, 2) NOT NULL,
KdvOrani decimal(5, 2) NOT NULL,
CONSTRAINT PK_Urun PRIMARY KEY (UrunId),
CONSTRAINT UQ_Urun_Barkod UNIQUE (Barkod),
CONSTRAINT CK_Urun_BirimFiyat CHECK (BirimFiyat >= 0),
CONSTRAINT CK_Urun_KdvOrani CHECK (KdvOrani BETWEEN 0 AND 100)
);
GO
CREATE TABLE satis.Siparis
(
SiparisId bigint IDENTITY(1, 1) NOT NULL,
MusteriId int NOT NULL,
SiparisTarihi datetime2(3) NOT NULL CONSTRAINT DF_Siparis_SiparisTarihi DEFAULT (SYSDATETIME()),
Durum varchar(20) NOT NULL CONSTRAINT DF_Siparis_Durum DEFAULT ('YENI'),
TeslimAdresi nvarchar(400) NULL,
CONSTRAINT PK_Siparis PRIMARY KEY (SiparisId),
CONSTRAINT FK_Siparis_Musteri FOREIGN KEY (MusteriId)
REFERENCES satis.Musteri (MusteriId),
CONSTRAINT CK_Siparis_Durum
CHECK (Durum IN ('YENI', 'HAZIRLANIYOR', 'KARGODA', 'TESLIM', 'IPTAL'))
);
GO
-- FOREIGN KEY sütunları otomatik indekslenmez
CREATE INDEX IX_Siparis_MusteriId ON satis.Siparis (MusteriId);
GO
CREATE TABLE satis.SiparisKalemi
(
SiparisId bigint NOT NULL,
SatirNo smallint NOT NULL,
UrunId int NOT NULL,
Miktar decimal(10, 3) NOT NULL,
BirimFiyat decimal(12, 2) NOT NULL,
Tutar AS (Miktar * BirimFiyat) PERSISTED,
CONSTRAINT PK_SiparisKalemi PRIMARY KEY (SiparisId, SatirNo),
CONSTRAINT FK_SiparisKalemi_Siparis FOREIGN KEY (SiparisId)
REFERENCES satis.Siparis (SiparisId) ON DELETE CASCADE,
CONSTRAINT FK_SiparisKalemi_Urun FOREIGN KEY (UrunId)
REFERENCES satis.Urun (UrunId),
CONSTRAINT CK_SiparisKalemi_Miktar CHECK (Miktar > 0)
);
GO
CREATE INDEX IX_SiparisKalemi_UrunId ON satis.SiparisKalemi (UrunId);
GO
INSERT INTO satis.Musteri (MusteriKodu, AdSoyad, Eposta, Il)
VALUES ('M0001', N'Örnek Müşteri A', 'a@example.com', N'İstanbul'),
('M0002', N'Örnek Müşteri B', NULL, N'Kocaeli');
INSERT INTO satis.Urun (Barkod, UrunAdi, BirimFiyat, KdvOrani)
VALUES ('8690000000001', N'Örnek Ürün', 250.00, 20.00);
INSERT INTO satis.Siparis (MusteriId) VALUES (1);
INSERT INTO satis.SiparisKalemi (SiparisId, SatirNo, UrunId, Miktar, BirimFiyat)
VALUES (1, 1, 1, 2, 250.00);
SELECT s.SiparisId, m.AdSoyad, k.UrunId, k.Miktar, k.Tutar
FROM satis.Siparis AS s
JOIN satis.Musteri AS m ON m.MusteriId = s.MusteriId
JOIN satis.SiparisKalemi AS k ON k.SiparisId = s.SiparisId;
GO
Tasarımdaki kararlar:
- PRIMARY KEY ve IDENTITY: Kümelenmiş anahtar dar, değişmeyen ve artan bir değer olmalı;
int IDENTITYbu yüzden iyi bir varsayılan. IDENTITY boşluksuz numara garanti etmez; geri alınan işlemler ve yeniden başlatmalar numara atlatabilir. Fatura numarası gibi kesintisiz seri gereken yerlerde ayrı bir mekanizma kurun. - İş anahtarı UNIQUE ile korunur:
MusteriKoduveBarkodbirincil anahtar değil ama tekrar etmemeli. UNIQUE kısıtı bunun için arka planda benzersiz bir indeks oluşturur. - FOREIGN KEY indeksi: SQL Server yabancı anahtar sütununa otomatik indeks açmaz. Birleştirme (JOIN) ve silme işlemlerinin tabloyu taramaması için bu indeksleri kendiniz ekleyin. İndeks ve sorgu planı tarafını SQL Server performans ayarı: indeks, istatistik ve sorgu planı yazısında ayrıntılı anlattım.
- ON DELETE CASCADE: Sipariş silinince kalemlerinin de silinmesi mantıklı; müşteri silinince siparişlerin silinmesi değil. Bu yüzden kademeli silmeyi yalnızca kalem tablosunda kullandım.
- Hesaplanan sütun:
Tutarsaklanmaz,Miktar * BirimFiyatifadesinden türetilir;PERSISTEDile diske yazılır ve indekslenebilir.
ALTER TABLE: mevcut tabloyu değiştirmek
ALTER TABLE, daha önce oluşturulmuş bir tablonun yapısını değiştirir. Eski notumdaki ADD tel varchar(20) NOT NULL satırı, tabloda veri varsa hata verir. Microsoft belgesindeki kural açık: yeni sütun NULL kabul etmiyorsa ve tablo boş değilse sütunla birlikte bir DEFAULT tanımı eklenmelidir.
-- 1) Boş geçilebilir sütun eklemek her zaman mümkündür
ALTER TABLE satis.Musteri
ADD VergiNo varchar(11) NULL;
GO
-- 2) Dolu tabloya NOT NULL sütun: DEFAULT ile birlikte
ALTER TABLE satis.Musteri
ADD MusteriTipi char(1) NOT NULL
CONSTRAINT DF_Musteri_MusteriTipi DEFAULT ('B');
GO
-- 3) Sütun tipini değiştirmek: NULL/NOT NULL'ı açıkça yazın
ALTER TABLE satis.Musteri
ALTER COLUMN Telefon varchar(30) NULL;
GO
-- 4) Kısıt eklemek: mevcut veriyi de doğrula
ALTER TABLE satis.Musteri WITH CHECK
ADD CONSTRAINT CK_Musteri_MusteriTipi CHECK (MusteriTipi IN ('B', 'K'));
GO
-- 5) Sütun adını değiştirmek
EXEC sp_rename N'satis.Musteri.Telefon', N'CepTelefonu', N'COLUMN';
GO
-- 6) Kısıt ve sütun kaldırmak (SQL Server 2016+)
ALTER TABLE satis.Musteri DROP CONSTRAINT IF EXISTS CK_Musteri_MusteriTipi;
ALTER TABLE satis.Musteri DROP CONSTRAINT IF EXISTS DF_Musteri_MusteriTipi;
ALTER TABLE satis.Musteri DROP COLUMN IF EXISTS MusteriTipi;
GO
-- Güvenilmeyen (WITH NOCHECK ile eklenmiş) kısıtları bulmak
SELECT name, is_disabled, is_not_trusted
FROM sys.check_constraints
WHERE parent_object_id = OBJECT_ID(N'satis.Musteri');
Dikkat edilecek noktalar:
- SQL Server 2012 ve sonrası Enterprise sürümünde, varsayılan değeri çalışma zamanı sabiti olan bir NOT NULL sütun eklemek çevrimiçi (online) işlemdir; diğer durumlarda büyük tablolarda tablo yeniden yazılabilir, bakım penceresinde yapın.
ALTER COLUMN, PRIMARY KEY, FOREIGN KEY ya da DEFAULT ile ilişkili bir sütunda tip değişikliğine izin vermez. Önce kısıtı kaldırmanız, sonra yeniden oluşturmanız gerekir.- Bir sütunu kaldırmadan önce ona bağlı DEFAULT ve CHECK kısıtlarını kaldırın; örnekteki sıra bu yüzden böyle.
WITH NOCHECKile eklenen kısıtlar mevcut veriyi doğrulamaz ve sorgu iyileştiricisi bu kısıtlara güvenmez. Zorunlu kalmadıkçaWITH CHECKkullanın.
DROP, TRUNCATE ve DELETE farkı
Eski notta “DROP TABLE tabloyu ve verisini siler” diye tek satır vardı. Üç komut arasındaki fark, üretimde yanlış komutun bedelini belirler:
| Özellik | DELETE | TRUNCATE TABLE | DROP TABLE |
|---|---|---|---|
| Ne kaldırır | Seçilen satırlar (WHERE ile) | Tüm satırlar | Tablo, veri, indeks, kısıt, tetikleyici |
| Günlükleme | Her satır ayrı kaydedilir | Yalnızca sayfa serbest bırakma kaydedilir | Nesne kaldırma |
| IDENTITY sayacı | Korunur | Başlangıç değerine döner | Tabloyla birlikte gider |
| Tetikleyici | DELETE tetikleyicisi çalışır | Çalışmaz | Tetikleyici de silinir |
| FOREIGN KEY ile başvurulan tablo | Kısıt kurallarına göre | Kullanılamaz | Önce başvuran kısıt kaldırılmalı |
| İşlem içinde geri alma | Evet | Evet | Evet |
| Grup | DML | DDL | DDL |
DELETE FROM satis.Siparis WHERE Durum = 'IPTAL'; -- kalemler CASCADE ile silinir
TRUNCATE TABLE satis.SiparisKalemi; -- başvurulmayan tablo: izinli
-- TRUNCATE TABLE satis.Siparis; -- hata: SiparisKalemi başvuruyor
DROP TABLE IF EXISTS satis.SiparisKalemi, satis.Siparis, satis.Urun, satis.Musteri;
Tek DROP TABLE komutunda hem başvuran hem başvurulan tabloları listeliyorsanız başvuran (çocuk) tabloyu önce yazın. Üretimde DROP ve TRUNCATE öncesinde yedeğin alındığını ve doğru veritabanında olduğunuzu (SELECT DB_NAME();) kontrol etmek, her ekipte yazılı kural olmalı.
Sonuç ve kontrol listesi
- Her tablo bir şema altında, her kısıt açık adlı.
- Her sütunda
NULL/NOT NULLaçıkça yazılı; varsayılan NOT NULL. - Para
decimal, tarihdatetime2/date, çok dilli metinnvarchar. - İş anahtarları
UNIQUE, ilişkilerFOREIGN KEY, iş kurallarıCHECKile korunuyor. - Yabancı anahtar sütunları indeksli.
- Dolu tabloya NOT NULL sütun DEFAULT ile ekleniyor.
TRUNCATEveDROPyalnızca yedek ve doğru bağlam kontrolünden sonra.
Tablo tasarımı veri ambarı ve raporlama projelerinde de temel belirleyicidir; kurumsal ölçekte bunun nasıl bir iş zekâsı altyapısına dönüştüğünü Nebim V3 veri ambarı ve iş zekâsı röportajında anlatmıştım.
Sık sorulan sorular
SQL'de DROP, TRUNCATE ve DELETE arasındaki fark nedir?
DELETE seçilen satırları tek tek siler, WHERE ile süzülebilir ve tetikleyicileri çalıştırır. TRUNCATE TABLE tüm satırları sayfa düzeyinde kaldırır, IDENTITY sayacını başa alır ve FOREIGN KEY ile başvurulan tablolarda kullanılamaz. DROP TABLE ise tabloyu tanımı, indeksleri ve kısıtlarıyla birlikte veritabanından kaldırır.
SQL Server'da varchar mı nvarchar mı kullanmalıyım?
Türkçe dahil birden çok dilde metin saklanacaksa nvarchar en güvenli seçimdir. SQL Server 2019 ve sonrasında UTF-8 etkin bir harmanlama (collation) ile varchar da tüm Unicode karakterlerini saklayabilir. Yalnızca ASCII içeren kod, e-posta, barkod gibi alanlar için varchar yeterlidir.
Dolu bir tabloya NOT NULL sütun nasıl eklenir?
ALTER TABLE ... ADD ile sütunu NOT NULL olarak tanımlarken aynı komutta bir DEFAULT kısıtı verin; mevcut satırlar bu varsayılan değerle doldurulur. DEFAULT vermeden NOT NULL sütun eklemek yalnızca tablo boşsa mümkündür.
SQL Server'da TIMESTAMP veri tipi tarih saklar mı?
Hayır. SQL Server'da timestamp, rowversion veri tipinin eski eş anlamlısıdır ve satır her değiştiğinde artan ikili bir sürüm numarası tutar. Tarih ve saat için datetime2, saat dilimi bilgisi gerekiyorsa datetimeoffset kullanılmalıdır.
Kaynaklar
- Microsoft Learn — CREATE TABLE (Transact-SQL) learn.microsoft.com
- Microsoft Learn — ALTER TABLE (Transact-SQL) learn.microsoft.com
- Microsoft Learn — TRUNCATE TABLE (Transact-SQL) learn.microsoft.com
- Microsoft Learn — char and varchar (Transact-SQL) learn.microsoft.com
- Microsoft Learn — datetime2 (Transact-SQL) learn.microsoft.com
- Microsoft Learn — Unique constraints and check constraints learn.microsoft.com
