Expert: design, speed and engines · Schema design and normalisation · topic 45 of 62 · 35 min
Data types, naming and good habits
Explanation
Money: exact, always
Module 1 showed that 0.1 + 0.2 is 0.30000000000000004 in floating point. Here is why that matters in design. FLOAT, REAL and DOUBLE store numbers in binary, and most decimal fractions have no exact binary form, just as 1/3 has no exact decimal form. Small errors pile up and comparisons fail.
SELECT 0.1 + 0.2 = 0.3; -- 0 (false!)
SELECT 0.1 + 0.2 + 0.3; -- 0.6000000000000001
SELECT ROUND(2.675, 2); -- 2.67, not 2.68: 2.675 is really 2.67499999...
SELECT (10 + 20) = 30; -- 1 (paise as integers are exact)- PostgreSQL, MySQL, SQL Server: use DECIMAL(12, 2) or NUMERIC(12, 2). The first number is the total digits, the second the digits after the point.
- SQLite (no true DECIMAL type) and many payment systems: store whole paise as INTEGER. ₹1,234.50 is 123450. Format it as rupees only when you show it.
- Store the currency too (a currency column holding 'INR') if the app may ever handle more than one.
Dates, times and time zones
A timestamp without a time zone is ambiguous: "2025-03-28 18:45" in Mumbai and in London are five and a half hours apart. The safe habit is to store every moment in UTC (Coordinated Universal Time) and convert to the user's zone only for display. India Standard Time is UTC+5:30, with no daylight saving.
- PostgreSQL: use TIMESTAMPTZ (timestamp with time zone). It stores the moment in UTC and shows it in the session's time zone.
- MySQL: TIMESTAMP is converted to UTC for storage; DATETIME is stored as typed, with no zone.
- SQLite: store UTC text 'YYYY-MM-DD HH:MM:SS'. CURRENT_TIMESTAMP and datetime('now') already return UTC.
- A pure calendar date with no time (a date of birth, a train's journey date) is a DATE; do not turn it into a timestamp.
SELECT datetime('2025-03-28 18:45:00', '+5 hours', '+30 minutes');
-- 2025-03-29 00:15:00 (the same moment shown in IST: already the next day)Text lengths
In PostgreSQL, TEXT and VARCHAR(n) perform the same; VARCHAR(n) is a rule, not a speed trick. Use a length limit only when the business has one: a 6-digit PIN code, a 10-character PAN, a 2-letter state code. SQLite ignores VARCHAR lengths, so use CHECK (length(pincode) = 6) when you need the rule there. Store codes with leading zeros, like PIN codes and phone numbers, as text, never as integers.
Allowed values: CHECK, enum or lookup table
| Option | How | Good for |
|---|---|---|
| CHECK constraint | status TEXT NOT NULL CHECK (status IN ('Placed', 'Shipped', 'Delivered', 'Cancelled')) | A short list that rarely changes; works in every engine |
| Enum type | PostgreSQL CREATE TYPE order_status AS ENUM (...); MySQL ENUM('Placed', ...) | Fixed lists; adding a value is easy, removing one is hard |
| Lookup table | order_statuses(code PK, label, sort_order) plus a foreign key | Lists that change, need extra details, or are edited by admins |
Naming conventions
- Use snake_case: order_items, created_at. Unquoted names in PostgreSQL are folded to lower case, so OrderDate silently becomes orderdate.
- Plural (customers) or singular (customer) table names: both are common. Pick one rule and use it everywhere.
- Name foreign keys after the table they point to: customer_id points to customers.id.
- Avoid reserved words (order, user, group) as names; they need quoting forever.
- Units in names: amount_paise, duration_seconds, weight_grams, so nobody guesses.
Audit columns and soft deletes
Add created_at and updated_at to most tables; they answer "when did this change?" during every support call. created_at can use DEFAULT CURRENT_TIMESTAMP. updated_at must be set on each update: MySQL has ON UPDATE CURRENT_TIMESTAMP, while PostgreSQL and SQLite need the app or a trigger (module 11).
A soft delete marks a row as deleted (deleted_at = the time) instead of removing it, so it can be restored and audited. The cost: every query must add WHERE deleted_at IS NULL, and UNIQUE rules must ignore deleted rows (a partial index, module 10). For personal data, privacy law may require a real delete, so check the rules.
Integer or UUID keys?
Integer ids are small, fast and readable, but they reveal how many rows you have and must come from one database. A UUID is a 128-bit random-looking value like 3f1c9a2e-... that any server can create without asking the database, which helps with offline apps and merging data. It is larger and, if fully random (version 4), scatters index inserts. Time-ordered UUIDs (version 7) reduce that; PostgreSQL has a uuid type and gen_random_uuid(), and PostgreSQL 18 added uuidv7(). Many teams use an integer key inside and a UUID in public URLs.
Common mistakes
- FLOAT for money. Fix: DECIMAL or integer paise.
- Storing local times without a zone. Fix: store UTC; convert on display.
- Phone numbers or PIN codes as INTEGER, losing leading zeros. Fix: TEXT with a CHECK on length.
- Mixed naming: OrderDate, order_date and orderdate in one schema. Fix: write a one-page naming guide.
- Soft-delete columns that queries forget to filter. Fix: a view that hides deleted rows (module 10).
Need help?
Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.
Try:
Watch the lesson
Data types, naming and good habits · 1/7
Small type and naming choices decide whether a schema stays healthy for years. Let us start with money.
Prefer a video by a teacher? Find free videos on “database design best practices data types money decimal utc timestamps naming conventions” on YouTube ↗
In real life: A payments ledger
Payment systems in India move amounts in paise. A typical ledger table stores amount_paise as a BIGINT, the currency code, a UTC created_at, and a status limited by a CHECK or enum. The app shows ₹1,234.50 on screen, but every addition, refund and reconciliation is done on exact integers, so totals always match the bank statement to the paisa.
CREATE TABLE ledger (
id INTEGER PRIMARY KEY,
amount_paise INTEGER NOT NULL CHECK (amount_paise > 0),
currency TEXT NOT NULL DEFAULT 'INR' CHECK (length(currency) = 3),
status TEXT NOT NULL CHECK (status IN ('Pending', 'Success', 'Failed', 'Refunded')),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP -- UTC
);Practice (0/4 done)
See the float problem for yourself in one SELECT (no table needed). Return three columns: float_sum (0.1 + 0.2), float_equal (whether 0.1 + 0.2 = 0.3) and paise_equal (whether 10 + 20 = 30).
payments stores money as whole paise in amount_paise. Return id, payer, amount_paise and rupees, where rupees is the amount formatted with exactly two decimals as text, for example '1234.50'. Use printf('%.2f', amount_paise / 100.0).
1) Create a table shipments with id (INTEGER PRIMARY KEY) and status (TEXT NOT NULL with CHECK (status IN ('Placed', 'Shipped', 'Delivered', 'Cancelled'))). 2) Try inserting (4, 'Pending') and read the error. 3) Insert the valid rows (1, 'Placed'), (2, 'Shipped'), (3, 'Delivered'). 4) Return id and status of every shipment.
logins stores created_at in UTC. Show each login in India Standard Time (UTC+5:30). Return id, user_name, created_at and created_ist, where created_ist is datetime(created_at, '+5 hours', '+30 minutes').
Quick quiz
Read more (free)
Related help
Before this, learn
This topic builds on these. If something here feels new, open them first.
Related topics
- Design project: an online storeSchema design and normalisation · same module
- Normalisation: 1NF, 2NF, 3NF and BCNFSchema design and normalisation · same module
- Designing the data modelFull-Stack Development · Planning an app
- Dates and timesJavaScript Programming (language in depth) · Modules, errors, JSON and dates
- CREATE TABLE and constraintsDatabases and the relational model
- TriggersProcedures, triggers, SQL engines and NoSQL
Questions people ask about this
Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.
What is the safest way to store ₹1,234.50 in SQLite?
Answer: INTEGER 123450 (paise)
Whole paise in an integer are exact; format as rupees only for display.
Why store timestamps in UTC?
Answer: Each moment has one unambiguous value, and display code converts it to any zone
Mixed local times cannot be compared safely. UTC is one shared clock.
Order statuses change often and admins want to add labels and a sort order. What fits best?
Answer: A lookup table with a foreign key
A lookup table can be edited like data and can hold extra details.
Why store PIN codes and phone numbers as TEXT?
Answer: You never do maths on them, and integers drop leading zeros
Codes are labels made of digits, not quantities.
What is the main cost of soft deletes?
Answer: Every query and unique rule must ignore rows where deleted_at is set
Forgotten filters show "deleted" rows. Views and partial indexes help.
A SQLite column is declared VARCHAR(3). What happens when you insert 'ABCDEFG'?Related
Answer: All 7 characters are stored
SQLite gives VARCHAR TEXT affinity and ignores the length. PostgreSQL would reject it.
Why do many apps use a soft delete (is_active = 0) for customers?Related
Answer: Old orders, invoices and audits still need the row
The row is hidden from normal use but kept for history.
Which column should NOT exist in the users table?Related
Answer: password (the plain password)
Plain passwords must never be stored. Only a hash of the password is kept.
What is the UTC offset of IST?Related
Pass the quiz (60% or more) or mark this topic as done when you are ready.
Your progress is saved on this device only.
Next steps
- 1 more topic in “Schema design and normalisation” before its exam: Design project: an online store.
- Module 9 exam: Schema design and normalisation · 13 questions · 25 min, without help. 5 topics of this module still to finish first.
- Then the next module, Indexes, query plans and views. 62 topics left in the course before the final exam.