Course topics

By WebNest Studio Engineering Team

Database Design Tutorial

Audit Fields in Database Design

Audit fields are standard columns added to tables purely to answer "who changed this, and when" — separate from the business data itself. The most common set is created_at, updated_at, and created_by (sometimes updated_by as well), and they earn their place on nearly every table in a production schema because they are cheap to add and disproportionately valuable when something needs to be debugged, disputed, or reported on later.

Without audit fields, answering a question like "was this price always wrong, or did someone change it yesterday" requires guesswork or external logs that may not exist. With them, the answer is a single query against the table itself.

Why it matters

"When did this change, and who changed it?" is asked in every bug investigation, customer dispute and compliance review. If the answer was never recorded it cannot be recovered afterwards. A few standard columns on every table cost almost nothing and repeatedly save the day.

created_at and updated_at

created_at records the exact moment a row was inserted and should never change afterward; updated_at records the most recent modification and should be refreshed on every update. Both should default to the current timestamp at insert time, and updated_at is best maintained automatically (via a trigger or an ORM hook) rather than relying on every application code path to remember to set it manually — a single forgotten update statement anywhere in the codebase otherwise leaves it stale.

created_by and updated_by

In any system with more than one user or service capable of writing data, created_by (and often updated_by) records which user, account, or service performed the write. This is essential for accountability in collaborative systems — knowing which staff member adjusted a price, which admin deleted a record, or which background job wrote a batch of rows — and it is far harder to reconstruct after the fact than to capture at write time.

Why Audit Fields Matter Beyond Debugging

Audit fields support far more than developer debugging: customer support needs created_at to confirm when an order was placed, finance needs updated_at and updated_by to investigate a disputed price change, and compliance requirements in many industries mandate a traceable record of who modified sensitive data. Because retrofitting audit fields onto a live table with years of existing rows means those historical rows will have incomplete or null audit data, it is far cheaper to include these columns from the very first migration of a table than to add them later.

  • created_at — TIMESTAMP, set once at insert, never modified afterward.
  • updated_at — TIMESTAMP, refreshed automatically on every update.
  • created_by — references the user/service that inserted the row.
  • updated_by — references the user/service that made the most recent change.

Example

A table with a full audit field set, and a trigger keeping updated_at accurate

SQL
CREATE TABLE invoices (
    id           BIGINT PRIMARY KEY,
    customer_id  BIGINT NOT NULL REFERENCES customers(id),
    amount       DECIMAL(12, 2) NOT NULL,
    created_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    created_by   BIGINT NOT NULL REFERENCES users(id),
    updated_by   BIGINT NOT NULL REFERENCES users(id)
);

CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = CURRENT_TIMESTAMP;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_invoices_updated_at
BEFORE UPDATE ON invoices
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
Output
Inserting a row stamps created_at and updated_at with the same
timestamp and records the inserting user via created_by. Any later
UPDATE to the row automatically refreshes updated_at through the
trigger regardless of which application code path performed the
update, so it can never be accidentally left stale.

Common Mistakes

  • Relying on application code to manually set updated_at on every update statement, which inevitably gets forgotten in at least one code path.
  • Adding audit fields only after a dispute or incident has already made their absence a problem, leaving historical rows with incomplete audit data.
  • Recording created_by/updated_by as a plain text name instead of a foreign key to the users table, making the audit trail unreliable if a user is later renamed or deleted.
  • Confusing updated_at (last modification time) with created_at (never-changing insert time) and updating the wrong one, or updating both on every write.

Exercise

Create an articles table with a created_at column that is filled in automatically with the current time and an updated_at column that starts empty. Insert an article, then update its title and set updated_at to the current time in the same statement. Finally check that both timestamps are set and that the update is not earlier than the creation.

Expected output: has_created | updated_ok then 1 | 1

SQL · starter code
-- TODO: articles (id, title, created_at filled automatically, updated_at)

INSERT INTO articles (id, title) VALUES (1, 'Draft');

-- TODO: change the title to 'Final' and set updated_at to the current time

SELECT created_at IS NOT NULL AS has_created, updated_at >= created_at AS updated_ok
FROM articles;
Show hints
  • A default supplies the creation time: created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP.
  • In the update, set two columns: SET title = ..., updated_at = CURRENT_TIMESTAMP.
Show solution
SQL · solution
CREATE TABLE articles (
  id INTEGER PRIMARY KEY,
  title TEXT NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP
);

INSERT INTO articles (id, title) VALUES (1, 'Draft');

UPDATE articles
SET title = 'Final', updated_at = CURRENT_TIMESTAMP
WHERE id = 1;

SELECT created_at IS NOT NULL AS has_created, updated_at >= created_at AS updated_ok
FROM articles;

Quick quiz

1. How is created_at best filled in?

  • A. By the user typing it
  • B. By a nightly job
  • C. It should be left NULL
  • D. By a column default of the current timestamp
Show answer

D. A default means no code path can forget it.

2. Why is a trigger often used for updated_at?

  • A. It sets the value on every update, even when the statement that made the change forgot to
  • B. Triggers are faster than updates
  • C. Defaults cannot be timestamps
  • D. It makes the column unique
Show answer

A. A default applies only on insert, so updates need another mechanism.

3. In which time zone should audit timestamps be stored?

  • A. The zone of the server
  • B. UTC
  • C. The zone of each user
  • D. It does not matter
Show answer

B. UTC is unambiguous; conversion to local time happens when the value is displayed.

Interview questions

What audit columns do you add to tables, and why?

Show answer

created_at and updated_at record when a row was inserted and last changed; created_by and updated_by record which user or service did it. They answer questions during debugging, support, security review and compliance, and they are useful to the application itself, for sorting by recency or finding rows changed since the last synchronisation. They are cheap to add at the start and impossible to reconstruct later.

Are audit columns enough to see the full history of a row?

Show answer

No. They show only the time and author of the latest change, not what the previous values were or how many changes there have been. A full history needs an audit or history table that receives a row for every change, usually written by a trigger, or an event log. Audit columns cover the common question "when was this last touched"; history tables cover "what did it look like before".

Key Points to Remember

  • created_at and updated_at are cheap, standard columns that let anyone answer "when was this written or changed" directly from the table.
  • created_by and updated_by provide accountability in any system where more than one user or service can write data.
  • updated_at should be maintained automatically (trigger or ORM hook), not manually, so it is never accidentally skipped.
  • Add audit fields from a table's first migration — retrofitting them later leaves historical rows with incomplete data.

Practice in the playground

Write and run your own code for this topic, or start from the exercise or an example. Runs your SQL on SQLite in your browser. Standard SQL works; a few features that exist only in PostgreSQL or MySQL do not. Your code for each topic is kept in this browser.

Run the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

These examples need a project environment on your computer, so they can be copied but not run here. Codelab runs Python, FastAPI backends with SQLite, plain Java 17 and HTML/CSS/JavaScript.

WebNest Studio

Written by

WebNest Studio Engineering Team

Practical lessons from developers working with Java, Python, React, databases and production systems.

Led by Vansh Duggal, Founder, WebNest Studio · Full-stack & software engineering