Database SQL PostgreSQL

Database Design & SQL — ออกแบบฐานข้อมูลที่ดีและเขียน SQL อย่างมืออาชีพ 🗄️Database Design & SQL — Designing Good Databases and Writing SQL Like a Professional 🗄️

By Anirach Mingkhwan DevOps & Vibe Coding 2026
Database & SQL — ออกแบบฐานข้อมูลที่ดี
ในบทความนี้
  1. 📊 Database Types — เลือกอะไรดี?
  2. 🏗️ Database Design — ออกแบบยังไงให้ดี?
  3. 📝 SQL Essentials — คำสั่งที่ต้องรู้
  4. ⚡ Indexing — ทำให้ Query เร็วขึ้น 100x
  5. 🔄 Migrations — จัดการ Schema Changes
  6. 🛡️ Security — ป้องกัน SQL Injection
  7. 🔄 Transactions — All or Nothing
  8. 🏗️ ORM vs Query Builder vs Raw SQL
  9. 📋 Database Design Checklist
  10. 🔑 Key Takeaways
In this post
  1. 📊 Database Types — Which One Should You Pick?
  2. 🏗️ Database Design — How Do You Design It Well?
  3. 📝 SQL Essentials — The Statements You Must Know
  4. ⚡ Indexing — Making Queries 100x Faster
  5. 🔄 Migrations — Managing Schema Changes
  6. 🛡️ Security — Preventing SQL Injection
  7. 🔄 Transactions — All or Nothing
  8. 🏗️ ORM vs Query Builder vs Raw SQL
  9. 📋 Database Design Checklist
  10. 🔑 Key Takeaways

ทุก App ที่คุณใช้ทุกวันมี Database อยู่ข้างหลัง — LINE (messages, contacts), Shopee (products, orders), YouTube (videos, comments), Banking App (accounts, transactions) 🏦

💡 "App without a database is just a pretty picture" — Code เปลี่ยนได้ทุกวัน, Framework เปลี่ยนได้ทุกปี แต่ Database schema ที่ออกแบบผิด → ปวดหัวไปตลอดชีวิต

📊 Database Types — เลือกอะไรดี?

ก่อนที่เราจะเจาะลึกเรื่อง SQL เราต้องเข้าใจก่อนว่ามี Database หลายประเภท แต่ละแบบมีจุดเด่นและเหมาะกับงานแบบไหน การเลือก Database ที่ถูกต้องตั้งแต่เริ่มต้นจะช่วยให้โปรเจคของคุณเติบโตได้อย่างมีประสิทธิภาพ

ในโลกของ Database มี 2 กลุ่มใหญ่ที่เราต้องเลือก: Relational Database (SQL) ที่ข้อมูลมีโครงสร้างและความสัมพันธ์ที่ชัดเจน กับ NoSQL Database ที่ยืดหยุ่นในเรื่องโครงสร้างมากกว่า แต่ละแบบมีเหตุผลในการใช้งานที่แตกต่างกัน

Relational (SQL)
MySQL / MariaDB
SQLite
SQL Server
  • ข้อมูลมีความสัมพันธ์กัน
  • ต้องการ ACID
  • Complex queries
  • Transaction สำคัญ
  • 90% ของ apps
NoSQL
MongoDB (Document)
Redis (Key-Value)
Cassandra (Wide)
Neo4j (Graph)
  • Schema ไม่แน่นอน
  • ต้องการ scale แนวนอน
  • High throughput
  • Real-time / Cache
  • Specific use cases

จากการเปรียบเทียบข้างต้น คุณจะเห็นว่า Relational Database ยังคงเป็นตัวเลือกหลักสำหรับแอปพลิเคชันส่วนใหญ่ เพราะข้อมูลในแอปทั่วไปมักมีความสัมพันธ์กัน เช่น ลูกค้า-คำสั่งซื้อ, โพสต์-ความคิดเห็น, ผู้ใช้-เพื่อน

ในกลุ่ม Relational Database เอง PostgreSQL เป็นตัวเลือกที่แนะนำที่สุดในปัจจุบัน ด้วยความสามารถที่หลากหลายและฟีเจอร์ที่ทันสมัย แต่หลายๆ องค์กรยังใช้ MySQL เป็นหลัก มาดูเปรียบเทียบกันว่าต่างกันอย่างไร

PostgreSQL vs MySQL — เปรียบเทียบ

PostgreSQL ⭐MySQL
LicenseBSD (เสรีจริงๆ)GPL (Oracle)
ACID✅ Full✅ (InnoDB)
JSON support⭐ JSONB (เร็ว)JSON (ช้ากว่า)
Full-text search✅ Built-in✅ Basic
ExtensionsPostGIS, pg_trgm, pgvector, timescaleLimited
Complex queries⭐ CTE, Window functions, lateral✅ OK
PerformanceComplex queries ดีกว่าSimple reads ดีกว่า
ใช้โดยInstagram, Stripe, Discord, RedditFacebook, Uber, Airbnb, Twitter

จากตารางเปรียบเทียบข้างต้น คุณจะเห็นว่า PostgreSQL มีความได้เปรียบในหลายๆ ด้าน โดยเฉพาะการรองรับ JSONB ที่ทำให้สามารถเก็บข้อมูลแบบ NoSQL ได้ภายใน Relational Database เดียวกัน รวมถึง Extensions ที่ทรงพลังเช่น PostGIS สำหรับข้อมูลเชิงพื้นที่

MySQL ก็ยังมีจุดเด่นในเรื่องความเร็วของ Simple Reads และการมี Community ที่ใหญ่ แต่สำหรับโปรเจคใหม่ PostgreSQL มักจะเป็นทางเลือกที่ดีกว่าเพราะความยืดหยุ่นและฟีเจอร์ที่ครบครัน

💡 แนะนำ: เริ่มต้นที่ PostgreSQL — ทำได้ทุกอย่าง
SQLite สำหรับ prototype / mobile / embedded

🏗️ Database Design — ออกแบบยังไงให้ดี?

การออกแบบ Database ที่ดีคือรากฐานของแอปพลิเคชันที่มีประสิทธิภาพ การออกแบบที่ผิดพลาดจะทำให้เราต้องแก้ไขในภายหลังด้วยความยากลำบาก ซึ่งเป็นเรื่องที่ค่อนข้างเสี่ยงและใช้เวลามาก

หลักการสำคัญในการออกแบบคือการลด Data Redundancy (ข้อมูลซ้ำซ้อน) และรักษา Data Integrity (ความถูกต้องของข้อมูล) มาดูตัวอย่างระบบ E-Commerce แบบง่ายๆ กันว่าการออกแบบที่ถูกและผิดต่างกันอย่างไร

ตัวอย่าง: ระบบ E-Commerce

สมมติว่าเราต้องเก็บข้อมูลลูกค้า สินค้า และคำสั่งซื้อ หลายๆ คนเริ่มต้นด้วยการยัดข้อมูลทุกอย่างลงใน table เดียว ซึ่งดูง่ายแต่จะสร้างปัญหาใหญ่ในอนาคต

❌ แบบผิด — ยัดทุกอย่างใน table เดียว

orders table (🤮 ทุกอย่างอยู่ที่เดียว):

id | customer_name | customer_email | customer_phone | product_name | product_price | product_category | quantity | total | order_date | shipping_address | shipping_city | ...

ปัญหา:

  • ข้อมูลซ้ำ: customer_name ซ้ำทุก order
  • แก้ยาก: เปลี่ยนเบอร์โทร → ต้องแก้ทุก row
  • Inconsistent: บาง row ชื่อ "สมชาย" บาง row "Somchai"
  • ช้า: table ใหญ่ขึ้นเรื่อยๆ query ช้าลง
✅ แบบถูก — แยก tables + สร้างความสัมพันธ์
customers
id PK
name
email
phone
created_at
←
orders
id PK
customer_id FK
order_date
status
total
→
products
id PK
name
price
category_id
stock
order_items
order_id FK
product_id FK
quantity
unit_price

ตัวอย่างข้างต้นแสดงให้เห็นความแตกต่างอย่างชัดเจนระหว่างการออกแบบแบบผิดและถูก แบบผิดจะทำให้ข้อมูลซ้ำซ้อน แก้ไขยาก และเกิดความไม่สอดคล้องได้ง่าย ขณะที่แบบถูกจะแยกข้อมูลออกเป็นหลาย tables และใช้ Foreign Keys เชื่อมความสัมพันธ์

การแยก tables ด้วย Primary Keys และ Foreign Keys ทำให้:

  • ข้อมูลลูกค้าเก็บครั้งเดียว → แก้ที่เดียวส่งผลทั่วระบบ
  • สินค้าหมดสต็อก → อัพเดตที่เดียวแสดงผลทุกที่
  • เพิ่มฟีเจอร์ใหม่ → ไม่ต้องแก้ table เดิม
  • Query เร็วขึ้น → table เล็กลง index มีประสิทธิภาพมากขึ้น

หลักการนี้เรียกว่า Normalization ซึ่งเป็นกระบวนการลดความซ้ำซ้อนของข้อมูลอย่างเป็นระบบ

Normalization — ลดความซ้ำซ้อน

1NF (First Normal Form)
ทุก column มีค่าเดียว (atomic)
❌ phone: "081-xxx, 082-xxx"
✅ แยกเป็น phone_numbers table
2NF (Second Normal Form)
ผ่าน 1NF + ทุก column ขึ้นกับ PK ทั้งหมด (ไม่ใช่แค่บางส่วน)
❌ order_items(order_id, product_id, product_name)
product_name ขึ้นกับ product_id อย่างเดียว
✅ ย้าย product_name ไป products table
3NF (Third Normal Form)
ผ่าน 2NF + ไม่มี transitive dependency
❌ orders(customer_id, customer_name, customer_city)
customer_name ขึ้นกับ customer_id ไม่ใช่ order
✅ ย้ายข้อมูล customer ไป customers table

Normalization แต่ละระดับมีจุดประสงค์และข้อดีที่ชัดเจน 1NF ทำให้ข้อมูลมีโครงสร้างพื้นฐาน 2NF ลดการเก็บข้อมูลซ้ำที่เกิดจาก Composite Keys และ 3NF ตัดการพึ่งพาทางอ้อมออกไป

ในการทำงานจริง การออกแบบถึง 3NF มักจะเพียงพอแล้ว ถ้าไปไกลกว่านี้จะซับซ้อนเกินความจำเป็น ส่วน Denormalization (การรวม tables กลับเข้าด้วยกัน) ควรทำเฉพาะเมื่อมี Performance Issue จริงๆ และวัดผลได้ชัดเจน

💡 ในทางปฏิบัติ: ออกแบบถึง 3NF ก็เพียงพอ
Denormalize เฉพาะเมื่อ performance ต้องการจริงๆ

เมื่อออกแบบ Database เสร็จแล้ว ขั้นตอนต่อไปคือการเรียนรู้ SQL (Structured Query Language) ซึ่งเป็นภาษาสำหรับจัดการข้อมูลใน Relational Database ทุกระบบ SQL มีพื้นฐานคล้ายๆ กัน แต่แต่ละตัวก็มี Extensions และฟีเจอร์พิเศษของตัวเอง


📝 SQL Essentials — คำสั่งที่ต้องรู้

CREATE TABLE — สร้าง Table

การสร้าง Table เป็นขั้นตอนแรกของการทำงานกับ Database คำสั่ง CREATE TABLE ใน SQL ใช้ในการกำหนดโครงสร้างของ Table รวมถึง Data Types, Constraints, และความสัมพันธ์ต่างๆ

ตัวอย่างนี้จะแสดงการสร้าง Tables สำหรับระบบ E-Commerce ที่เรากล่าวถึงก่อนหน้านี้ โดยใช้ PostgreSQL Syntax โปรดสังเกต Constraints ต่างๆ เช่น PRIMARY KEY, FOREIGN KEY, CHECK, UNIQUE ที่ช่วยรักษาความถูกต้องของข้อมูล

-- ═══════════════════════════════
-- สร้าง Tables สำหรับ E-Commerce
-- ═══════════════════════════════

-- Categories
CREATE TABLE categories (
    id          SERIAL PRIMARY KEY,
    name        VARCHAR(100) NOT NULL UNIQUE,
    description TEXT,
    parent_id   INT REFERENCES categories(id),  -- self-reference
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Products
CREATE TABLE products (
    id          SERIAL PRIMARY KEY,
    name        VARCHAR(255) NOT NULL,
    slug        VARCHAR(255) NOT NULL UNIQUE,     -- URL-friendly name
    description TEXT,
    price       DECIMAL(10,2) NOT NULL CHECK (price >= 0),
    stock       INT NOT NULL DEFAULT 0 CHECK (stock >= 0),
    category_id INT REFERENCES categories(id),
    is_active   BOOLEAN DEFAULT true,
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

จากตัวอย่างข้างต้น คุณจะเห็นการใช้ Constraints ต่างๆ ที่สำคัญ:

  • SERIAL PRIMARY KEY → สร้าง auto-increment ID
  • NOT NULL → บังคับไม่ให้เป็นค่าว่าง
  • UNIQUE → ไม่ให้มีค่าซ้ำ (เช่น email, slug)
  • CHECK → กำหนดเงื่อนไข (เช่น price >= 0)
  • REFERENCES → สร้าง Foreign Key เชื่อม Tables
  • DEFAULT → ค่าเริ่มต้นถ้าไม่ได้ใส่ข้อมูล

การเลือก Data Type ที่เหมาะสมสำคัญมาก เพราะส่งผลต่อ Storage Space, Performance และความถูกต้องของข้อมูล มาดู Data Types ที่ใช้บ่อยในแต่ละหมวดหมู่

Data Types ที่ใช้บ่อย

Text
VARCHAR(n) จำกัดความยาว
TEXT ไม่จำกัดความยาว
CHAR(n) ความยาวคงที่
Numbers
INT/INTEGER ตัวเลขจำนวนเต็ม
DECIMAL(p,s) ตัวเลขแม่นยำ (เงิน!)
SERIAL auto-increment
Date/Time
TIMESTAMP วันที่ + เวลา
TIMESTAMPTZ + timezone (แนะนำ!)
DATE วันที่อย่างเดียว
Other
BOOLEAN true/false
JSONB JSON (searchable)
UUID unique identifier

การเลือก Data Type ที่เหมาะสมจะช่วยประหยัด Storage และเพิ่ม Performance VARCHAR เหมาะกับข้อมูลที่มีความยาวไม่แน่นอน TEXT สำหรับข้อมูลยาวๆ เช่น บทความ INT สำหรับตัวเลขจำนวนเต็ม และ DECIMAL สำหรับการคำนวณที่ต้องการความแม่นยำ

โดยเฉพาะ TIMESTAMP WITH TIME ZONE (TIMESTAMPTZ) ใน PostgreSQL ซึ่งแนะนำมากเพราะจัดการ Time Zone ให้อัตโนมัติ ไม่ต้องไปปวดหัวกับปัญหาเวลาที่แตกต่างกันในแต่ละประเทศ

💡 เงิน → DECIMAL ไม่ใช่ FLOAT!
FLOAT: 0.1 + 0.2 = 0.30000000000000004
DECIMAL: 0.1 + 0.2 = 0.3 ✅

เมื่อสร้าง Tables เสร็จแล้ว ขั้นตอนต่อไปคือการทำงานกับข้อมูล หรือที่เราเรียกว่า CRUD Operations ซึ่งครอบคลุมการทำงานพื้นฐานทั้งหมดที่เราต้องทำกับ Database

CRUD — Create, Read, Update, Delete

-- ═══════════════════════════════
-- INSERT — เพิ่มข้อมูล
-- ═══════════════════════════════

-- เพิ่ม 1 record
INSERT INTO customers (name, email, phone, password_hash)
VALUES ('สมชาย ใจดี', '[email protected]', '081-234-5678', '$2b$10$hash...');

-- เพิ่มหลาย records
INSERT INTO products (name, slug, price, stock, category_id) VALUES
    ('iPhone 16 Pro', 'iphone-16-pro', 45900.00, 50, 1),
    ('MacBook Air M4', 'macbook-air-m4', 42900.00, 30, 2),
    ('AirPods Pro 3', 'airpods-pro-3', 8900.00, 100, 3);

-- INSERT + RETURNING (ได้ค่ากลับมา)
INSERT INTO orders (customer_id, status, total)
VALUES (1, 'pending', 54800.00)
RETURNING id, ordered_at;

คำสั่ง INSERT ข้างต้นแสดงวิธีการเพิ่มข้อมูลทั้งแบบ Single Record และ Multiple Records พร้อมกัน การใช้ INSERT ... RETURNING เป็นฟีเจอร์ที่มีประโยชน์มาก เพราะจะได้ค่า ID หรือค่าอื่นๆ ที่ Database สร้างให้กลับมาทันที

แต่การใส่ข้อมูลเพียงอย่างเดียวไม่เพียงพอ เราต้องสามารถ Query ข้อมูลจากหลาย Tables พร้อมกันด้วย นี่คือจุดที่ JOIN เข้ามามีบทบาทสำคัญ

JOIN — เชื่อมข้อมูลจากหลาย Tables

-- ═══════════════════════════════
-- INNER JOIN — เอาเฉพาะที่ match ทั้ง 2 ฝั่ง
-- ═══════════════════════════════

SELECT c.name, o.id AS order_id, o.total, o.status
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id;

-- LEFT JOIN — เอาทุก row จากซ้าย + match จากขวา (ถ้ามี)
SELECT c.name, COUNT(o.id) AS order_count
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name;
-- → ได้ลูกค้าทุกคน แม้ยังไม่เคยสั่ง (order_count = 0)

JOIN เป็นหัวใจสำคัญของ Relational Database ที่ทำให้เราสามารถเชื่อมข้อมูลจากหลาย Tables มาแสดงผลร่วมกัน INNER JOIN จะได้เฉพาะข้อมูลที่ match ทั้งสองฝั่ง ส่วน LEFT JOIN จะได้ข้อมูลทั้งหมดจากฝั่งซ้าย และข้อมูลจากฝั่งขวาเฉพาะที่ match

การเข้าใจ JOIN Types ต่างๆ เป็นสิ่งสำคัญ เพราะจะส่งผลต่อผลลัพธ์ที่ได้ เช่น การนับจำนวน Orders ของลูกค้าแต่ละคน ถ้าใช้ INNER JOIN จะได้เฉพาะลูกค้าที่เคยสั่งซื้อ แต่ถ้าใช้ LEFT JOIN จะได้ลูกค้าทุกคน รวมถึงคนที่ยังไม่เคยสั่งซื้อ (แสดงเป็น 0)

แต่เมื่อข้อมูลมากขึ้น Query จะช้าลง ซึ่งนี่คือจุดที่ Indexing เข้ามาช่วยทำให้ Query เร็วขึ้นอย่างมหาศาล


⚡ Indexing — ทำให้ Query เร็วขึ้น 100x

Without Index
Database: "ต้อง scan ทุก row..."
📄📄📄📄📄📄📄📄📄📄 → 1,000,000 rows
⏱️ 2.3 seconds
With Index
Database: "ดู index → ไปตรง row ที่ต้องการ"
🔍 → 📄 → found!
⏱️ 0.002 seconds (1000x เร็วขึ้น!)

การเปรียบเทียบข้างต้นแสดงให้เห็นพลังของ Index อย่างชัดเจน การที่ Database ต้อง Scan ข้อมูล 1 ล้าน Records ใช้เวลา 2.3 วินาที แต่เมื่อมี Index แล้วใช้เวลาแค่ 0.002 วินาที นั่นคือเร็วขึ้น 1000 เท่า!

Index = สารบัญหนังสือ — ไม่มี index = ต้องอ่านหนังสือทั้งเล่มเพื่อหาหน้าที่ต้องการ

แต่ Index ไม่ใช่ยาวิเศษที่ใส่ได้ไม่จำกัด Index แต่ละตัวใช้ Storage และทำให้ INSERT/UPDATE ช้าลง เพราะต้องไปอัพเดต Index ด้วย ดังนั้นต้องสร้าง Index อย่างชาญฉลาดตามการใช้งานจริง

-- ═══════════════════════════════
-- สร้าง Index
-- ═══════════════════════════════

-- B-tree (default) — ใช้ได้กับ =, <, >, BETWEEN, ORDER BY
CREATE INDEX idx_products_price ON products(price);
CREATE INDEX idx_orders_status ON orders(status);

-- Unique index — ป้องกันค่าซ้ำ + เร็ว
CREATE UNIQUE INDEX idx_customers_email ON customers(email);

-- Composite index — หลาย columns
CREATE INDEX idx_orders_customer_status
ON orders(customer_id, status);

-- Partial index — index เฉพาะบาง rows
CREATE INDEX idx_active_products
ON products(name, price)
WHERE is_active = true;

ตัวอย่างการสร้าง Index ข้างต้นแสดงหลายแบบที่ใช้ในงานจริง:

  • B-tree Index (default) → เหมาะกับ equality และ range queries
  • Unique Index → ป้องกันข้อมูลซ้ำและเร็วกว่า Index ธรรมดา
  • Composite Index → Index หลาย columns พร้อมกัน เหมาะกับ Query ที่ใช้หลาก column ใน WHERE
  • Partial Index → Index เฉพาะ rows ที่ตรงเงื่อนไข ประหยัด space และเร็วกว่า

หลังจากสร้าง Index แล้ว เราจำเป็นต้องตรวจสอบว่า Database ใช้ Index หรือไม่ ผ่านคำสั่ง EXPLAIN ANALYZE ที่เป็นเครื่องมือสำคัญสำหรับ Performance Tuning

EXPLAIN ANALYZE — วิเคราะห์ Performance

EXPLAIN ANALYZE
SELECT p.name, p.price
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE c.name = 'Electronics'
  AND p.price < 50000
  AND p.is_active = true
ORDER BY p.price DESC
LIMIT 10;

-- ผลลัพธ์:
-- Limit  (cost=12.45..12.48 rows=10) (actual time=0.089..0.091 rows=10 loops=1)
--   ->  Sort  (cost=12.45..12.58 rows=50) (actual time=0.088..0.089 rows=10)
--         Sort Key: p.price DESC
--         ->  Nested Loop  (cost=0.43..11.89 rows=50) (actual time=0.031..0.072)
--               ->  Index Scan using idx_categories_name on categories c  ← ✅ ใช้ index!
--               ->  Index Scan using idx_products_category on products p  ← ✅ ใช้ index!

ผลลัพธ์จาก EXPLAIN ANALYZE แสดงให้เห็น Query Plan ที่ Database เลือกใช้ การที่เห็น "Index Scan" แสดงว่า Database ใช้ Index เราได้สำเร็จ ส่วนถ้าเห็น "Seq Scan" (Sequential Scan) แสดงว่าไม่มี Index ที่เหมาะสม จำเป็นต้องไปสร้างเพิ่มเติม

ค่า "cost" และ "actual time" ที่แสดงออกมาช่วยให้เราเปรียบเทียบ Performance ได้ และค่า "rows" บอกจำนวน Records ที่ประมวลผล ข้อมูลเหล่านี้ช่วยให้เราตัดสินใจได้ว่าควรสร้าง Index เพิ่มหรือไม่

แต่การสร้าง Index เป็นเพียงส่วนหนึ่งของการจัดการ Database เราต้องมีวิธีจัดการการเปลี่ยนแปลง Schema อย่างเป็นระบบด้วย ซึ่งนั่นคือจุดที่ Database Migrations เข้ามามีบทบาท


🔄 Migrations — จัดการ Schema Changes

ทำไมต้อง Migrations? → ทุก schema change เป็น code file, version controlled (git), รันซ้ำได้, rollback ได้, ทุกคนใน team ได้ schema เดียวกัน

Migration ด้วย Knex.js (Node.js)

// migrations/20260307_001_create_products.js

exports.up = function(knex) {
  return knex.schema.createTable('products', (table) => {
    table.increments('id');
    table.string('name', 255).notNullable();
    table.string('slug', 255).notNullable().unique();
    table.text('description');
    table.decimal('price', 10, 2).notNullable();
    table.integer('stock').defaultTo(0);
    table.integer('category_id')
      .references('id').inTable('categories');
    table.boolean('is_active').defaultTo(true);
    table.timestamps(true, true);   // created_at, updated_at

    // Indexes
    table.index('category_id');
    table.index('price');
    table.index(['is_active', 'price']);  // composite
  });
};

exports.down = function(knex) {
  return knex.schema.dropTable('products');
};

ตัวอย่าง Migration ข้างต้นแสดงโครงสร้างที่ดี มีทั้ง up() function สำหรับการเปลี่ยนแปลง และ down() function สำหรับการ rollback ถ้าเกิดปัญหา การที่ Migration เป็น Code ทำให้เราสามารถใส่ไว้ใน Git, Review, และทำงานเป็น Team ได้

ข้อดีของ Migrations:

  • Version Control → Schema changes ติดตาม Git ได้
  • Reproducible → รันบน Dev, Staging, Production ได้เหมือนกัน
  • Rollback → ย้อนกลับได้ถ้าเกิดปัญหา
  • Team Sync → ทุกคนใน team ได้ Schema เดียวกัน
  • Automation → รัน CI/CD ได้อัตโนมัติ

หลังจากที่เราจัดการ Schema และ Performance แล้ว ยังมีอีกเรื่องสำคัญที่ห้ามลืม นั่นคือ Security โดยเฉพาะการป้องกัน SQL Injection ที่เป็นช่องโหว่อันตรายที่สุดอันดับต้นๆ


🛡️ Security — ป้องกัน SQL Injection

SQL Injection — อันตรายที่สุดของ web apps

// ❌ String concatenation (อย่าทำ!!!)
// User input: ' OR '1'='1' --
const query = `SELECT * FROM users
  WHERE email = '${email}'
  AND password = '${password}'`;

// กลายเป็น:
SELECT * FROM users
WHERE email = '' OR '1'='1' --'
AND password = 'anything'
// → Login ได้โดยไม่ต้องรู้ password!

// ✅ Parameterized queries (ทำแบบนี้เสมอ!)
const query = 'SELECT * FROM users WHERE email = $1 AND password = $2';
db.query(query, [email, password]);

ตัวอย่างข้างต้นแสดงความแตกต่างระหว่าง String Concatenation ที่อันตรายกับ Parameterized Queries ที่ปลอดภัย เมื่อใช้ Parameterized Queries Database จะแยก SQL Structure กับ Data ออกจากกัน ทำให้ไม่สามารถ Inject คำสั่ง SQL เพิ่มเติมได้

กฎทองของการป้องกัน SQL Injection:

  • ไม่เอา User Input มา Concat กับ SQL โดยตรง → ไม่มีข้อยกเว้น
  • ใช้ Parameterized Queries เสมอ → $1, $2, ? หรือ :name
  • Validate Input → ตรวจสอบ Type, Range, Format
  • ใช้ ORM/Query Builder → มี Protection built-in
  • Least Privilege → DB User มีสิทธิ์เฉพาะที่จำเป็น

นอกจาก Security แล้ว เรายังต้องรู้วิธีจัดการกับ Operations ที่ซับซ้อน ที่จำเป็นต้อง "ทำสำเร็จทั้งหมดหรือไม่ทำเลย" ซึ่งนั่นคือแนวคิดของ Database Transactions


🔄 Transactions — All or Nothing

-- Transaction — การสั่งซื้อต้อง atomic
-- ถ้า step ไหน fail → rollback ทั้งหมด!
BEGIN;

-- 1. สร้าง order
INSERT INTO orders (customer_id, status, total)
VALUES (1, 'confirmed', 54800.00)
RETURNING id INTO order_id;

-- 2. เพิ่ม order items
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES
    (order_id, 1, 1, 45900.00),
    (order_id, 3, 1, 8900.00);

-- 3. ลด stock
UPDATE products SET stock = stock - 1 WHERE id = 1;
UPDATE products SET stock = stock - 1 WHERE id = 3;

COMMIT;
-- ถ้าทุกอย่าง OK → COMMIT
-- ถ้ามี error → ROLLBACK อัตโนมัติ

ACID Properties:

  • A - Atomicity → ทำทั้งหมดหรือไม่ทำเลย
  • C - Consistency → ข้อมูลถูกต้องเสมอ
  • I - Isolation → Transaction แต่ละตัวไม่เห็นกัน
  • D - Durability → COMMIT แล้ว = บันทึกถาวร

ตัวอย่าง Transaction ข้างต้นแสดงการสั่งซื้อสินค้าที่ต้องทำหลายขั้นตอนพร้อมกัน ถ้าขั้นตอนใดล้มเหลว (เช่น สินค้าหมด, ข้อมูลผิด) ทุกอย่างจะถูก Rollback กลับสู่สถานะเดิม ป้องกันไม่ให้เกิด Inconsistent Data

ACID Properties ทำให้ Database เชื่อถือได้:

  • Atomicity → ป้องกันข้อมูลครึ่งๆ กลางๆ
  • Consistency → รักษา Business Rules
  • Isolation → Transactions ไม่รบกวนกัน
  • Durability → ข้อมูล Commit แล้วไม่หาย

เมื่อเราเข้าใจ SQL พื้นฐานแล้ว คำถามต่อไปคือควรเขียน Raw SQL หรือใช้เครื่องมือช่วยเช่น ORM หรือ Query Builder มาดูเปรียบเทียบกันว่าแต่ละแบบเหมาะกับสถานการณ์ไหน


🏗️ ORM vs Query Builder vs Raw SQL

ORMQuery BuilderRaw SQL
ตัวอย่างPrisma, SQLAlchemy, TypeORMKnex.js, Kyselypg, psycopg2
Abstractionสูงกลางต่ำ
เรียนรู้ง่ายกลางต้องรู้ SQL
Complex queriesยากขึ้นยืดหยุ่น⭐ ยืดหยุ่นสุด
Type safety⭐ ดีมาก✅ OK❌ ไม่มี
Performanceดี (แต่ N+1 problem)⭐ ใกล้ raw⭐ เร็วสุด
เหมาะกับCRUD apps, rapid devComplex queriesAnalytics, optimization

การเปรียบเทียบข้างต้นแสดงให้เห็นว่าแต่ละ Approach มีจุดเด่นต่างกัน ORM เหมาะกับ Developer ที่ต้องการ Type Safety และ Productivity สูง แต่อาจมีข้อจำกัดใน Complex Queries และมีความเสี่ยงของ N+1 Query Problem

Query Builder เป็น Middle Ground ที่ดี มีความยืดหยุ่นใกล้เคียง Raw SQL แต่ยังมี Type Safety บางส่วน เหมาะกับ Team ที่มี SQL Skills ดีและต้องการควบคุม Queries มากขึ้น

Raw SQL ให้ Performance และความยืดหยุ่นสูงสุด เหมาะกับ Analytics, Reporting, หรือ Optimization ขั้นสูง แต่ต้องระวังเรื่อง SQL Injection และการ Maintain

💡 แนะนำ: Prisma/SQLAlchemy ORM สำหรับ CRUD ทั่วไป + Raw SQL สำหรับ complex analytics queries

เมื่อเราเข้าใจเทคนิคต่างๆ แล้ว สิ่งสำคัญคือการนำไปใช้อย่างเป็นระบบ Checklist ต่อไปนี้จะช่วยให้คุณออกแบบและจัดการ Database อย่างมืออาชีพ


📋 Database Design Checklist

📐Schema Design
[ ] Normalize ถึง 3NF
[ ] ใช้ meaningful table/column names
[ ] snake_case สำหรับ naming
[ ] ทุก table มี Primary Key
[ ] ใช้ Foreign Keys + ON DELETE
[ ] มี created_at, updated_at
⚡Performance
[ ] Index บน Foreign Keys
[ ] Index บน columns ที่ใช้ WHERE/JOIN บ่อย
[ ] EXPLAIN ANALYZE queries ที่ช้า
[ ] ใช้ DECIMAL สำหรับเงิน (ไม่ใช่ FLOAT)
[ ] Pagination (LIMIT/OFFSET)
🛡️Security
[ ] Parameterized queries เสมอ
[ ] Hash passwords (bcrypt/argon2)
[ ] ไม่เก็บ sensitive data แบบ plaintext
[ ] ใช้ least-privilege DB users
[ ] Backup strategy (daily + point-in-time)
🔄Operations
[ ] ใช้ Migrations สำหรับทุก schema change
[ ] Connection pooling
[ ] Soft delete (is_active) แทน hard delete
[ ] Monitor slow queries

Checklist ข้างต้นครอบคลุมจุดสำคัญทุกด้านของการทำงานกับ Database ตั้งแต่การออกแบบ Performance การรักษาความปลอดภัย และการ Operations ที่ดี การทำตาม Checklist เหล่านี้จะช่วยให้ Database ของคุณมีคุณภาพและพร้อมรองรับการเติบโตของแอปพลิเคชัน

การออกแบบ Database ที่ดีไม่ใช่เรื่องที่ทำได้ในคืนเดียว แต่เป็นทักษะที่พัฒนาได้ผ่านการฝึกฝนและประสบการณ์ สิ่งสำคัญคือการเริ่มต้นด้วยหลักการที่ถูกต้อง และค่อยๆ ปรับปรุงเมื่อมีความเข้าใจมากขึ้น

จุดสำคัญที่ควรจำไว้คือ Database เป็น Foundation ของแอปพลิเคชัน การลงทุนเวลาทำความเข้าใจและออกแบบให้ดีตั้งแต่เริ่มต้นจะประหยัดเวลาและลดปัญหาในอนาคตได้มากมาย


🔑 Key Takeaways

  1. PostgreSQL → แนะนำเป็นตัวเลือกแรก — ทำได้ทุกอย่าง, JSONB, extensions, free
  2. Normalize ถึง 3NF → ลดข้อมูลซ้ำ, denormalize เมื่อ performance ต้องการ
  3. Index ทุก FK + WHERE columns → EXPLAIN ANALYZE เพื่อตรวจสอบ
  4. Parameterized queries เสมอ → ป้องกัน SQL Injection (ห้าม concat string!)
  5. Transactions → ACID = all or nothing, ใช้สำหรับ multi-step operations
  6. DECIMAL สำหรับเงิน → ห้ามใช้ FLOAT! (0.1 + 0.2 ≠ 0.3)
  7. Migrations → ทุก schema change เป็น code, version controlled
  8. ORM + Raw SQL → ORM สำหรับ CRUD, Raw SQL สำหรับ analytics
  9. Window Functions → RANK, SUM OVER, ROW_NUMBER — analytics ขั้นสูง
  10. Soft delete → อย่า DELETE จริง, ใช้ is_active = false

Every app you use every day has a database behind it — LINE (messages, contacts), Shopee (products, orders), YouTube (videos, comments), Banking App (accounts, transactions) 🏦

💡 "App without a database is just a pretty picture" — code can change every day, frameworks can change every year, but a database schema you designed wrong → a headache for the rest of your life

📊 Database Types — Which One Should You Pick?

Before we dive into SQL, we need to understand that there are several kinds of database, each with its own strengths and its own best-fit workload. Choosing the right database from the start is what lets your project grow efficiently.

In the database world there are two big families to choose between: Relational Database (SQL), where the data has a clear structure and clear relationships, and NoSQL Database, which is far more flexible about structure. Each exists for a different reason.

Relational (SQL)
MySQL / MariaDB
SQLite
SQL Server
  • The data is related
  • You need ACID
  • Complex queries
  • Transactions matter
  • 90% of apps
NoSQL
MongoDB (Document)
Redis (Key-Value)
Cassandra (Wide)
Neo4j (Graph)
  • The schema is unsettled
  • You need horizontal scale
  • High throughput
  • Real-time / Cache
  • Specific use cases

From the comparison above you can see that a Relational Database is still the main choice for most applications, because data in an ordinary app tends to be related: customers to orders, posts to comments, users to friends.

Within the relational family, PostgreSQL is the most recommended choice today, thanks to its breadth of capability and its modern feature set — but plenty of organizations still run MySQL as their mainstay. Let us compare the two.

PostgreSQL vs MySQL — A Comparison

PostgreSQL ⭐MySQL
LicenseBSD (genuinely free)GPL (Oracle)
ACID✅ Full✅ (InnoDB)
JSON support⭐ JSONB (fast)JSON (slower)
Full-text search✅ Built-in✅ Basic
ExtensionsPostGIS, pg_trgm, pgvector, timescaleLimited
Complex queries⭐ CTE, Window functions, lateral✅ OK
PerformanceBetter on complex queriesBetter on simple reads
Used byInstagram, Stripe, Discord, RedditFacebook, Uber, Airbnb, Twitter

From the table above you can see that PostgreSQL has the advantage on several fronts, especially its JSONB support, which lets you store NoSQL-shaped data inside the same relational database — plus powerful extensions such as PostGIS for spatial data.

MySQL still has its strengths: fast simple reads and a very large community. But for a new project PostgreSQL is usually the better choice, because of its flexibility and how complete its feature set is.

💡 Recommended: start with PostgreSQL — it can do everything
SQLite for prototypes / mobile / embedded

🏗️ Database Design — How Do You Design It Well?

A good database design is the foundation of an efficient application. A bad one means painful repairs later — risky work that eats a lot of time.

The core principle of design is to reduce Data Redundancy (duplicated data) and preserve Data Integrity (correct data). Let us look at a simple e-commerce system and see how the right and wrong designs differ.

Example: An E-Commerce System

Suppose we need to store customers, products and orders. Many people start by cramming everything into a single table, which looks easy and creates a large problem down the road.

❌ Wrong — everything crammed into one table

orders table (🤮 everything in one place):

id | customer_name | customer_email | customer_phone | product_name | product_price | product_category | quantity | total | order_date | shipping_address | shipping_city | ...

Problems:

  • Duplicated data: customer_name repeats on every order
  • Hard to change: a new phone number → you must edit every row
  • Inconsistent: one row says "Somchai Jaidee", another "Somchai Jaidi" — the same customer, spelled two ways
  • Slow: the table keeps growing and the queries keep slowing down
✅ Right — separate tables + real relationships
customers
id PK
name
email
phone
created_at
←
orders
id PK
customer_id FK
order_date
status
total
→
products
id PK
name
price
category_id
stock
order_items
order_id FK
product_id FK
quantity
unit_price

The example above shows the difference between the wrong and the right design very plainly. The wrong one duplicates data, is hard to change, and drifts into inconsistency easily, while the right one splits the data across several tables and uses foreign keys to express the relationships.

Splitting tables with Primary Keys and Foreign Keys gives you this:

  • Customer data is stored once → fix it in one place and the whole system follows
  • A product goes out of stock → update it once and it shows everywhere
  • Add a new feature → no need to alter the old table
  • Queries get faster → smaller tables, more effective indexes

This principle is called Normalization: a systematic process for reducing data redundancy.

Normalization — Cutting the Redundancy

1NF (First Normal Form)
Every column holds a single value (atomic)
❌ phone: "081-xxx, 082-xxx"
✅ split it into a phone_numbers table
2NF (Second Normal Form)
Passes 1NF + every column depends on the whole PK (not just part of it)
❌ order_items(order_id, product_id, product_name)
product_name depends on product_id alone
✅ move product_name to the products table
3NF (Third Normal Form)
Passes 2NF + no transitive dependency
❌ orders(customer_id, customer_name, customer_city)
customer_name depends on customer_id, not on the order
✅ move the customer data to the customers table

Each level of normalization has a clear purpose and a clear payoff. 1NF gives the data its basic structure, 2NF removes the duplication that composite keys invite, and 3NF cuts out the indirect dependencies.

In real work, designing to 3NF is usually enough; going further gets more complicated than it needs to be. Denormalization (folding tables back together) should only happen when there is a genuine, measurable performance problem.

💡 In practice: designing to 3NF is enough
Denormalize only when performance genuinely demands it

Once the database is designed, the next step is learning SQL (Structured Query Language), the language for working with data in every relational database. The fundamentals of SQL are much the same everywhere, but each engine has its own extensions and special features.


📝 SQL Essentials — The Statements You Must Know

CREATE TABLE — Creating a Table

Creating a table is the first step of working with a database. The CREATE TABLE statement defines the table's structure, including data types, constraints and relationships.

This example creates the tables for the e-commerce system we discussed earlier, using PostgreSQL syntax. Note the constraints — PRIMARY KEY, FOREIGN KEY, CHECK, UNIQUE — that keep the data correct.

-- ═══════════════════════════════
-- Create the tables for E-Commerce
-- ═══════════════════════════════

-- Categories
CREATE TABLE categories (
    id          SERIAL PRIMARY KEY,
    name        VARCHAR(100) NOT NULL UNIQUE,
    description TEXT,
    parent_id   INT REFERENCES categories(id),  -- self-reference
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Products
CREATE TABLE products (
    id          SERIAL PRIMARY KEY,
    name        VARCHAR(255) NOT NULL,
    slug        VARCHAR(255) NOT NULL UNIQUE,     -- URL-friendly name
    description TEXT,
    price       DECIMAL(10,2) NOT NULL CHECK (price >= 0),
    stock       INT NOT NULL DEFAULT 0 CHECK (stock >= 0),
    category_id INT REFERENCES categories(id),
    is_active   BOOLEAN DEFAULT true,
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

In the example above you can see the important constraints at work:

  • SERIAL PRIMARY KEY → creates an auto-increment ID
  • NOT NULL → forbids an empty value
  • UNIQUE → forbids duplicates (email, slug)
  • CHECK → sets a condition (price >= 0)
  • REFERENCES → creates a foreign key linking tables
  • DEFAULT → the value used when none is supplied

Choosing the right data type matters a great deal, because it drives storage space, performance and the correctness of the data. Here are the types you will use most often, by category.

The Data Types You Will Use Most

Text
VARCHAR(n) bounded length
TEXT unbounded length
CHAR(n) fixed length
Numbers
INT/INTEGER whole numbers
DECIMAL(p,s) exact numbers (money!)
SERIAL auto-increment
Date/Time
TIMESTAMP date + time
TIMESTAMPTZ + timezone (recommended!)
DATE date only
Other
BOOLEAN true/false
JSONB JSON (searchable)
UUID unique identifier

Picking the right data type saves storage and buys performance. VARCHAR suits data of uncertain length, TEXT suits long content such as articles, INT suits whole numbers, and DECIMAL suits any calculation that has to be exact.

TIMESTAMP WITH TIME ZONE (TIMESTAMPTZ) in PostgreSQL deserves a special mention: it is strongly recommended because it handles time zones for you, so you never have to nurse a headache over times differing from country to country.

💡 Money → DECIMAL, not FLOAT!
FLOAT: 0.1 + 0.2 = 0.30000000000000004
DECIMAL: 0.1 + 0.2 = 0.3 ✅

Once the tables exist, the next step is working with the data — what we call CRUD Operations, which cover every basic thing you need to do with a database.

CRUD — Create, Read, Update, Delete

-- ═══════════════════════════════
-- INSERT — adding data
-- ═══════════════════════════════

-- add 1 record
INSERT INTO customers (name, email, phone, password_hash)
VALUES ('Somchai Jaidee', '[email protected]', '081-234-5678', '$2b$10$hash...');

-- add several records
INSERT INTO products (name, slug, price, stock, category_id) VALUES
    ('iPhone 16 Pro', 'iphone-16-pro', 45900.00, 50, 1),
    ('MacBook Air M4', 'macbook-air-m4', 42900.00, 30, 2),
    ('AirPods Pro 3', 'airpods-pro-3', 8900.00, 100, 3);

-- INSERT + RETURNING (get the values back)
INSERT INTO orders (customer_id, status, total)
VALUES (1, 'pending', 54800.00)
RETURNING id, ordered_at;

The INSERT statements above show how to add a single record and several records at once. INSERT ... RETURNING is a genuinely useful feature: you get the ID, or any other value the database generated, straight back.

But inserting data is not enough on its own — we also have to query across several tables at once. This is where JOIN takes center stage.

JOIN — Combining Data from Several Tables

-- ═══════════════════════════════
-- INNER JOIN — only rows that match on both sides
-- ═══════════════════════════════

SELECT c.name, o.id AS order_id, o.total, o.status
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id;

-- LEFT JOIN — every row from the left + matches from the right (if any)
SELECT c.name, COUNT(o.id) AS order_count
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name;
-- → every customer, even those who have never ordered (order_count = 0)

JOIN is the heart of a relational database: it is what lets us pull data from several tables and present it together. INNER JOIN returns only the rows that match on both sides, while LEFT JOIN returns everything from the left side and only the matching rows from the right.

Understanding the JOIN types matters, because they change the result. Counting each customer's orders with an INNER JOIN returns only the customers who have ordered; with a LEFT JOIN you get every customer, including the ones who never ordered (shown as 0).

But as the data grows, queries slow down — and that is where Indexing comes in and makes them enormously faster.


⚡ Indexing — Making Queries 100x Faster

Without Index
Database: "I have to scan every row..."
📄📄📄📄📄📄📄📄📄📄 → 1,000,000 rows
⏱️ 2.3 seconds
With Index
Database: "check the index → go straight to the row"
🔍 → 📄 → found!
⏱️ 0.002 seconds (1000x faster!)

The comparison above shows the power of an index very clearly. Scanning a million records takes the database 2.3 seconds; with an index it takes 0.002 seconds. That is a thousand times faster.

An index is a book's table of contents — with no index you have to read the whole book to find the page you want.

But an index is not magic you can sprinkle without limit. Each index costs storage and slows INSERT and UPDATE down, because the index has to be updated too. So create indexes intelligently, according to how the data is actually used.

-- ═══════════════════════════════
-- Creating indexes
-- ═══════════════════════════════

-- B-tree (default) — works with =, <, >, BETWEEN, ORDER BY
CREATE INDEX idx_products_price ON products(price);
CREATE INDEX idx_orders_status ON orders(status);

-- Unique index — blocks duplicates + fast
CREATE UNIQUE INDEX idx_customers_email ON customers(email);

-- Composite index — several columns
CREATE INDEX idx_orders_customer_status
ON orders(customer_id, status);

-- Partial index — indexes only some rows
CREATE INDEX idx_active_products
ON products(name, price)
WHERE is_active = true;

The index examples above cover several kinds you will use in real work:

  • B-tree Index (default) → suits equality and range queries
  • Unique Index → prevents duplicate data and is faster than an ordinary index
  • Composite Index → indexes several columns at once, for queries whose WHERE uses several columns
  • Partial Index → indexes only the rows matching a condition, saving space and running faster

Once the index exists we need to check whether the database actually uses it, via EXPLAIN ANALYZE — the essential tool for performance tuning.

EXPLAIN ANALYZE — Analyzing Performance

EXPLAIN ANALYZE
SELECT p.name, p.price
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE c.name = 'Electronics'
  AND p.price < 50000
  AND p.is_active = true
ORDER BY p.price DESC
LIMIT 10;

-- Result:
-- Limit  (cost=12.45..12.48 rows=10) (actual time=0.089..0.091 rows=10 loops=1)
--   ->  Sort  (cost=12.45..12.58 rows=50) (actual time=0.088..0.089 rows=10)
--         Sort Key: p.price DESC
--         ->  Nested Loop  (cost=0.43..11.89 rows=50) (actual time=0.031..0.072)
--               ->  Index Scan using idx_categories_name on categories c  ← ✅ index used!
--               ->  Index Scan using idx_products_category on products p  ← ✅ index used!

The EXPLAIN ANALYZE output shows the query plan the database chose. Seeing "Index Scan" means our index was used successfully; seeing "Seq Scan" (Sequential Scan) means there is no suitable index and one needs to be created.

The "cost" and "actual time" figures let us compare performance, and "rows" tells us how many records were processed. Together they tell us whether another index is warranted.

But creating indexes is only one part of managing a database. We also need a systematic way to manage schema changes — and that is where Database Migrations come in.


🔄 Migrations — Managing Schema Changes

Why migrations? → every schema change is a code file, version controlled (git), re-runnable, rollback-able, and everyone on the team ends up with the same schema.

Migrations with Knex.js (Node.js)

// migrations/20260307_001_create_products.js

exports.up = function(knex) {
  return knex.schema.createTable('products', (table) => {
    table.increments('id');
    table.string('name', 255).notNullable();
    table.string('slug', 255).notNullable().unique();
    table.text('description');
    table.decimal('price', 10, 2).notNullable();
    table.integer('stock').defaultTo(0);
    table.integer('category_id')
      .references('id').inTable('categories');
    table.boolean('is_active').defaultTo(true);
    table.timestamps(true, true);   // created_at, updated_at

    // Indexes
    table.index('category_id');
    table.index('price');
    table.index(['is_active', 'price']);  // composite
  });
};

exports.down = function(knex) {
  return knex.schema.dropTable('products');
};

The migration above shows the right shape: an up() function for the change and a down() function to roll it back if something goes wrong. Because a migration is code, it can live in git, be reviewed, and be worked on as a team.

What migrations buy you:

  • Version Control → schema changes are tracked in git
  • Reproducible → the same run on dev, staging and production
  • Rollback → go back if something goes wrong
  • Team Sync → everyone on the team gets the same schema
  • Automation → they run automatically in CI/CD

Having handled schema and performance, there is one more thing we must not forget: Security, and above all preventing SQL injection, one of the most dangerous vulnerabilities there is.


🛡️ Security — Preventing SQL Injection

SQL Injection — the most dangerous flaw in web apps

// ❌ String concatenation (never do this!!!)
// User input: ' OR '1'='1' --
const query = `SELECT * FROM users
  WHERE email = '${email}'
  AND password = '${password}'`;

// becomes:
SELECT * FROM users
WHERE email = '' OR '1'='1' --'
AND password = 'anything'
// → logged in without knowing the password!

// ✅ Parameterized queries (always do this!)
const query = 'SELECT * FROM users WHERE email = $1 AND password = $2';
db.query(query, [email, password]);

The example above shows the difference between dangerous string concatenation and safe parameterized queries. With parameterized queries the database keeps the SQL structure and the data apart, so no extra SQL can be injected.

The golden rules for preventing SQL injection:

  • Never concatenate user input straight into SQL → no exceptions
  • Always use parameterized queries → $1, $2, ? or :name
  • Validate input → check type, range and format
  • Use an ORM or query builder → the protection is built in
  • Least Privilege → the DB user gets only the rights it needs

Beyond security, we also need to know how to handle complex operations that must "all succeed or not happen at all" — which is the idea behind Database Transactions.


🔄 Transactions — All or Nothing

-- Transaction — placing an order has to be atomic
-- if any step fails → roll all of it back!
BEGIN;

-- 1. create the order
INSERT INTO orders (customer_id, status, total)
VALUES (1, 'confirmed', 54800.00)
RETURNING id INTO order_id;

-- 2. add the order items
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES
    (order_id, 1, 1, 45900.00),
    (order_id, 3, 1, 8900.00);

-- 3. decrement stock
UPDATE products SET stock = stock - 1 WHERE id = 1;
UPDATE products SET stock = stock - 1 WHERE id = 3;

COMMIT;
-- if everything is OK → COMMIT
-- if there is an error → ROLLBACK automatically

ACID Properties:

  • A - Atomicity → all of it happens, or none of it does
  • C - Consistency → the data is always valid
  • I - Isolation → transactions do not see each other
  • D - Durability → once committed = permanently recorded

The transaction above shows an order that takes several steps at once. If any step fails — out of stock, bad data — everything rolls back to its previous state, which is what stops inconsistent data from appearing.

The ACID properties are what make a database trustworthy:

  • Atomicity → prevents half-finished data
  • Consistency → preserves the business rules
  • Isolation → transactions do not disturb each other
  • Durability → committed data does not vanish

Now that we understand SQL fundamentals, the next question is whether to write raw SQL or reach for a helper such as an ORM or a query builder. Let us compare which suits which situation.


🏗️ ORM vs Query Builder vs Raw SQL

ORMQuery BuilderRaw SQL
ExamplesPrisma, SQLAlchemy, TypeORMKnex.js, Kyselypg, psycopg2
AbstractionHighMediumLow
LearningEasyMediumYou must know SQL
Complex queriesGets harderFlexible⭐ Most flexible
Type safety⭐ Excellent✅ OK❌ None
PerformanceGood (but N+1 problem)⭐ Close to raw⭐ Fastest
Best forCRUD apps, rapid devComplex queriesAnalytics, optimization

The comparison above shows that each approach has a different strength. ORM suits developers who want type safety and high productivity, but it can be limiting on complex queries and carries the risk of the N+1 query problem.

Query Builder is a good middle ground: nearly as flexible as raw SQL while keeping some type safety. It suits a team with solid SQL skills that wants more control over its queries.

Raw SQL gives the most performance and the most flexibility, and suits analytics, reporting or advanced optimization — but you have to stay careful about SQL injection and about maintenance.

💡 Recommended: a Prisma/SQLAlchemy ORM for ordinary CRUD + raw SQL for complex analytics queries

Once we understand the techniques, what matters is applying them systematically. The checklist below will help you design and run a database like a professional.


📋 Database Design Checklist

📐Schema Design
[ ] Normalize to 3NF
[ ] Use meaningful table/column names
[ ] snake_case for naming
[ ] Every table has a Primary Key
[ ] Use Foreign Keys + ON DELETE
[ ] Have created_at, updated_at
⚡Performance
[ ] Index on Foreign Keys
[ ] Index on columns used often in WHERE/JOIN
[ ] EXPLAIN ANALYZE the slow queries
[ ] Use DECIMAL for money (not FLOAT)
[ ] Pagination (LIMIT/OFFSET)
🛡️Security
[ ] Parameterized queries, always
[ ] Hash passwords (bcrypt/argon2)
[ ] Never store sensitive data in plaintext
[ ] Use least-privilege DB users
[ ] Backup strategy (daily + point-in-time)
🔄Operations
[ ] Use migrations for every schema change
[ ] Connection pooling
[ ] Soft delete (is_active) instead of hard delete
[ ] Monitor slow queries

The checklist above covers every important dimension of working with a database: design, performance, security and sound operations. Following it will keep your database in good shape and ready for the application to grow.

Good database design is not something you get right in a single night; it is a skill built through practice and experience. What matters is starting from the right principles and improving steadily as your understanding deepens.

The point to hold on to is that the database is the foundation of the application. Time invested in understanding it and designing it well at the start saves an enormous amount of time and trouble later.


🔑 Key Takeaways

  1. PostgreSQL → the recommended first choice — it does everything, JSONB, extensions, free
  2. Normalize to 3NF → cut the duplication, denormalize when performance demands it
  3. Index every FK + WHERE column → EXPLAIN ANALYZE to confirm
  4. Parameterized queries, always → prevents SQL injection (never concatenate strings!)
  5. Transactions → ACID = all or nothing, use them for multi-step operations
  6. DECIMAL for money → never FLOAT! (0.1 + 0.2 ≠ 0.3)
  7. Migrations → every schema change is code, version controlled
  8. ORM + Raw SQL → ORM for CRUD, raw SQL for analytics
  9. Window Functions → RANK, SUM OVER, ROW_NUMBER — advanced analytics
  10. Soft delete → do not really DELETE, set is_active = false
บทความจากซีรีส์ DevOps & Vibe Coding 2026From the DevOps & Vibe Coding 2026 series
← Previous
Automated Testing with GitHub — ทดสอบอัตโนมัติทุก Push
Next →
Authentication & Authorization — ระบบยืนยันตัวตนและจัดการสิทธิ์อย่างปลอดภัย