Databases, Schemas, and Types
Introduction
PostgreSQL organizes data in databases and schemas before you reach tables and columns. This chapter creates your first database and schema, sets search_path, builds tables with modern identity columns, and picks the right PostgreSQL data types—including TEXT, TIMESTAMPTZ, UUID, arrays, and a preview of JSON vs JSONB. These foundations match what every later SQL chapter assumes.
Prerequisites
- Installation and psql
- Connected to PostgreSQL as a user who can create databases (e.g.
postgresin Docker) - Familiarity with MySQL database basics helps if you are migrating
Databases vs Schemas
In MySQL, database and schema are often used interchangeably. In PostgreSQL they are different layers:
Cluster (one server instance)
└── Database: shop
├── Schema: public ← default namespace
├── Schema: billing
└── Schema: analytics
└── Table: orders ← shop.analytics.orders when fully qualified| Layer | Role |
|---|---|
| Database | Isolated container; separate connection with \c dbname |
| Schema | Namespace inside one database; avoids name clashes between modules |
public | Default schema where unqualified table names land |
Code explanation:
- One PostgreSQL cluster hosts many databases—users, extensions, and backups are often scoped per database
- Schemas group tables without spinning up another server—common for
stagingvsappmodules or multi-tenant patterns (advanced)
List and Create Databases
In psql, list databases:
-- Meta-command in psql (not standard SQL)
\lCreate a practice database:
CREATE DATABASE shop_dev
ENCODING 'UTF8'
LC_COLLATE 'en_US.UTF-8'
LC_CTYPE 'en_US.UTF-8'
TEMPLATE template0;Connect to it:
-- psql meta-command
\c shop_devOr from the shell:
psql "postgresql://postgres:learnpass@127.0.0.1:5432/shop_dev"Confirm current database:
SELECT current_database();Code explanation:
ENCODING 'UTF8'is the modern default—stores emoji and all Unicode without MySQL's historicutf8vsutf8mb4confusionLC_COLLATE/LC_CTYPEcontrol sort order and character classification—set at create time; changing them later requires dump/restoreTEMPLATE template0gives a clean empty database (template1is the default but can carry objects )
Idempotent create:
-- PostgreSQL 15+ supports IF NOT EXISTS on CREATE DATABASE
CREATE DATABASE shop_dev;
-- If it already exists, you get an error unless you use a client-side guard or drop firstTip
Docker shortcut
If you started Docker with POSTGRES_DB=hello_demo, that database already exists—use \c hello_demo or create shop_dev as above.
Drop Database
DROP DATABASE removes the database and all its schemas and tables:
-- Disconnect other sessions first, or use FORCE (PostgreSQL 13+)
DROP DATABASE IF EXISTS shop_old;Warning
No undo
There is no DROP DATABASE recycle bin. Back up with pg_dump before dropping—see Backup and Restore.
Compare with MySQL database management—MySQL uses CHARACTER SET utf8mb4; PostgreSQL uses ENCODING and locale at database creation.
Schemas and search_path
Create a schema for a billing module:
CREATE SCHEMA IF NOT EXISTS billing;List schemas:
-- psql
\dn
-- SQL
SELECT schema_name
FROM information_schema.schemata
ORDER BY schema_name;Set search path for the session—PostgreSQL looks here for unqualified names:
SHOW search_path;
-- Typical default: "$user", public
SET search_path TO billing, public;Create a table in a specific schema without changing search_path:
CREATE TABLE billing.invoices (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
amount NUMERIC(12, 2) NOT NULL
);Code explanation:
search_pathis like a list of "folders" checked left to right—first match winspublicis whereCREATE TABLE usersgoes by default- Fully qualified names (
billing.invoices) always work regardless ofsearch_path
Multi-Schema Organization (Concept)
| Pattern | Example |
|---|---|
| By module | billing, shipping, public |
| By environment | Rare—usually separate databases per env |
| Multi-tenant (advanced) | One schema per tenant with row-level security |
Keep learning schemas in public until Roles and Permissions.
Your First Table
Blog-style users table with modern identity and comments:
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL,
display_name TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_users_email UNIQUE (email)
);
COMMENT ON TABLE users IS 'Application user accounts';
COMMENT ON COLUMN users.email IS 'Login email; unique case-sensitively unless citext extension is used';Verify:
-- psql
\dt
\d users
-- SQL
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users'
ORDER BY ordinal_position;Code explanation:
GENERATED ALWAYS AS IDENTITYis the SQL-standard replacement forSERIAL—preferred for new tablesTEXThas no length penalty vsVARCHAR(n)in PostgreSQL—useTEXTunless you need a hard max lengthTIMESTAMPTZstores UTC internally and displays in the session timezone—prefer it overTIMESTAMPwithout time zoneUNIQUEconstraint enforces one row per email—same idea as MySQLUNIQUE KEY
Compare table creation with MySQL creating tables—no ENGINE=InnoDB or AUTO_INCREMENT syntax.
SERIAL vs IDENTITY vs Sequences
Older PostgreSQL code uses SERIAL:
CREATE TABLE legacy_notes (
id SERIAL PRIMARY KEY,
body TEXT NOT NULL
);SERIAL is shorthand: it creates an integer column plus a SEQUENCE and attaches a default nextval(...).
Explicit sequence (when you need shared counters or custom steps):
CREATE SEQUENCE order_number_seq START 1000 INCREMENT 1;
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_number BIGINT NOT NULL DEFAULT nextval('order_number_seq'),
user_id BIGINT NOT NULL,
total NUMERIC(10, 2) NOT NULL
);| Approach | When to use |
|---|---|
GENERATED ... AS IDENTITY | Default for primary keys—standard SQL, clear ownership |
SERIAL / BIGSERIAL | Legacy tutorials and quick scripts |
CREATE SEQUENCE + nextval() | Shared sequences, custom INCREMENT, or non-key defaults |
Tip
MySQL migrants
MySQL AUTO_INCREMENT maps to IDENTITY or SERIAL. There is no clustered index guarantee identical to InnoDB—PostgreSQL uses heap tables with indexes; behavior details matter at scale but not for learning CRUD.
DROP and TRUNCATE
Remove a table and its data permanently:
DROP TABLE IF EXISTS legacy_notes;Empty a table but keep structure (fast reset; triggers and identity rules still apply):
TRUNCATE TABLE users RESTART IDENTITY CASCADE;Code explanation:
TRUNCATEis faster thanDELETEwithout aWHEREclause but cannot be rolled back easily in some contexts—use inside transactions when unsureRESTART IDENTITYresetsIDENTITY/ sequence countersCASCADEalso truncates tables that foreign-key reference this table—use carefully; foreign keys come in Constraints and Keys
PostgreSQL Data Types
Numeric
| Type | Use |
|---|---|
SMALLINT, INTEGER, BIGINT | IDs, counts |
NUMERIC(p,s) | Money and exact decimals—like MySQL DECIMAL |
REAL, DOUBLE PRECISION | Approximate science metrics—not for currency |
-- Example column definitions
price NUMERIC(10, 2) NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0Strings
| Type | Notes |
|---|---|
TEXT | Unlimited length; recommended default |
VARCHAR(n) | Optional max length constraint |
CHAR(n) | Fixed width; pads with spaces |
PostgreSQL does not require VARCHAR(255) for indexing—unlike some MySQL habits.
Date and Time
| Type | Use |
|---|---|
DATE | Calendar date only |
TIME / TIMETZ | Time of day; TIMETZ includes offset |
TIMESTAMP | Date + time without time zone—avoid for events unless you know why |
TIMESTAMPTZ | Recommended for created_at, published_at |
SELECT NOW(); -- timestamptz in session TZ
SELECT CURRENT_TIMESTAMP; -- same family
SET TIME ZONE 'UTC'; -- affects display, not stored UTC for timestamptzBoolean
Native BOOLEAN—no MySQL TINYINT(1) workaround:
is_active BOOLEAN NOT NULL DEFAULT TRUEUUID
Store opaque IDs—common in APIs:
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE api_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id BIGINT NOT NULL,
label TEXT NOT NULL
);gen_random_uuid() is built into PostgreSQL 13+; older versions rely on pgcrypto.
Arrays
PostgreSQL columns can hold arrays—handy for tags and small lists:
CREATE TABLE posts (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title TEXT NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}'
);
-- Example query (insert covered in next chapter)
SELECT id, title FROM posts WHERE 'postgres' = ANY (tags);Code explanation:
TEXT[]is a variable-length list of stringsANY (tags)checks membership—GIN indexes can speed this up later
JSON vs JSONB (Preview)
| Type | Storage | Typical use |
|---|---|---|
JSON | Exact text copy of input | Logging raw payloads |
JSONB | Binary, decomposed | Querying, indexing, updates |
CREATE TABLE profiles (
user_id BIGINT PRIMARY KEY,
settings JSONB NOT NULL DEFAULT '{}'::jsonb
);Deep indexing and operators live in JSONB and Advanced Types. For document-first modeling, see MongoDB document basics.
Posts Table Exercise
Build on users—foreign key details expand in chapter 9:
CREATE TABLE posts (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
published_at TIMESTAMPTZ,
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX idx_posts_user_id ON posts (user_id);Check objects:
\dt
\d postsKeep shop_dev (or hello_demo) with users and posts for Insert and COPY.
PostgreSQL vs MySQL Type Quick Reference
| Concept | PostgreSQL | MySQL |
|---|---|---|
| Auto-increment PK | GENERATED AS IDENTITY | AUTO_INCREMENT |
| Long text | TEXT | TEXT / LONGTEXT |
| Boolean | BOOLEAN | TINYINT(1) |
| Datetime with TZ | TIMESTAMPTZ | DATETIME (no native TZ) |
| JSON indexed | JSONB + GIN | JSON + generated columns |
| Namespace | schema.table | database.table |
| Charset | UTF8 encoding | utf8mb4 |
FAQ
Schema vs database—which do I pick?
Use separate databases for hard isolation (different apps, restore units). Use schemas inside one database to organize modules or teams without extra connection overhead.
Why not use VARCHAR(255) everywhere?
PostgreSQL treats TEXT and VARCHAR similarly for performance. Use VARCHAR(n) only when the application or standard requires a max length.
Can I change LC_COLLATE after creation?
Not in place. Create a new database with the desired locale and pg_dump / restore, or use C / POSIX collation for locale-independent sorts if you plan migrations.
SERIAL or IDENTITY for new projects?
Prefer GENERATED ALWAYS AS IDENTITY. It is standard SQL and avoids subtle sequence-ownership surprises when cloning tables.
Where did my table go after CREATE TABLE?
Check search_path and \dt *.*. The table may live in another schema. SELECT * FROM pg_tables WHERE tablename = 'users'; shows the schema name.
JSON or JSONB?
Default to JSONB unless you must preserve exact key order and whitespace from input. Most apps query and merge JSON—JSONB is built for that.
Is UTF8 enough for emoji?
Yes—PostgreSQL UTF8 covers full Unicode. You do not need a separate utf8mb4 alias like MySQL.