Introduction to PostgreSQL Schemas: A Practical Guide

Build scalable, secure, and maintainable databases with practical schema patterns and Go implementation

Miftahul Huda
8 min readDec 29, 2024

Have you ever struggled with organizing your database when working on a multi-tenant application? Or perhaps you’re managing a large project with hundreds of tables and need a better way to organize them? PostgreSQL schemas might be just what you need.

What is a PostgreSQL Schema?

Imagine you’re organizing your closet. You might have different sections for work clothes, casual wear, sports gear, and accessories. Each section helps you find what you need quickly and keeps similar items together. PostgreSQL schemas work in the same way for your database — they’re like virtual containers that help you organize database objects such as tables, views, functions, and indexes into logical groups.

Illustration of organizing closet

Let’s break this down with a more detailed example. Think of a schema as an apartment building, where each apartment (schema) can contain different furniture (database objects). Just as you wouldn’t want to mix all the furniture from different apartments in one space, you might not want to mix tables from different parts of your application in a single schema. The “public” schema that comes with PostgreSQL by default is like the building’s lobby — it’s accessible to everyone by default, but you might want to keep some things in more private spaces.

In technical terms, a schema is a namespace that contains named database objects. The same object name can be used in different schemas without conflict. For example, you could have hr.employees and accounting.employees as two completely separate tables in different schemas. This is particularly useful in large applications where different modules or features might naturally use similar names for their objects.

Going deeper into the apartment building analogy, schemas also provide security boundaries. Just as you might give different keys to different residents, you can grant different permissions to different schemas. Some users might have access to view and modify everything in the hr schema but only view access to the accounting schema, while others might have no access to certain schemas at all.

Another powerful aspect of schemas is their role in multi-tenant applications. Imagine if instead of apartments, each schema represented a different store in a shopping mall. Each store (tenant) can have its own set of tables, views, and functions, completely isolated from other tenants. This isolation makes it easier to manage, backup, and secure each tenant’s data independently.

Why Use Schemas?

Let’s understand this with a real-world example. Imagine you’re building a SaaS platform for restaurants to manage their menus, orders, and inventory. Without schemas, your tables might look like this:

-- All tables in public schema
CREATE TABLE restaurants (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
location VARCHAR(200)
);

CREATE TABLE menu_items (
id SERIAL PRIMARY KEY,
restaurant_id INTEGER REFERENCES restaurants(id),
name VARCHAR(100),
price DECIMAL(10,2)
);

CREATE TABLE orders (
id SERIAL PRIMARY KEY,
restaurant_id INTEGER REFERENCES restaurants(id),
total_amount DECIMAL(10,2)
);

With schemas, you can organize these better:

-- Create schemas for different aspects of the application
CREATE SCHEMA restaurant_management;
CREATE SCHEMA ordering_system;
CREATE SCHEMA analytics;

-- Restaurant management tables
CREATE TABLE restaurant_management.restaurants (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
location VARCHAR(200)
);

CREATE TABLE restaurant_management.menu_items (
id SERIAL PRIMARY KEY,
restaurant_id INTEGER REFERENCES restaurant_management.restaurants(id),
name VARCHAR(100),
price DECIMAL(10,2)
);

-- Ordering system tables
CREATE TABLE ordering_system.orders (
id SERIAL PRIMARY KEY,
restaurant_id INTEGER REFERENCES restaurant_management.restaurants(id),
total_amount DECIMAL(10,2)
);

-- Analytics tables
CREATE TABLE analytics.daily_sales (
date DATE,
restaurant_id INTEGER,
total_sales DECIMAL(10,2)
);

Understanding Schema Search Path

PostgreSQL uses a schema search path to determine which schema to look in when an object is referenced without a schema prefix. Think of it like your computer’s PATH environment variable — it tells the system where to look for things:

-- View current search path
SHOW search_path;

-- Set a new search path
SET search_path TO store, customers, public;

-- Now you can reference tables without schema prefix
SELECT * FROM products; -- Will look in store.products first

Best Practices for Schema Management

Naming Conventions: Use clear, descriptive names for schemas

CREATE SCHEMA customer_data;    -- Good
CREATE SCHEMA cd; -- Not so good

Schema Search Path: Set the search path to control which schemas are searched

-- Set search path for the current session
SET search_path TO restaurant_management, public;

Access Control: Use schemas for security boundaries

-- Grant access to specific schemas
GRANT USAGE ON SCHEMA restaurant_management TO restaurant_staff;
GRANT SELECT ON ALL TABLES IN SCHEMA restaurant_management TO restaurant_staff;

Schema Migration: Keep schema creation and management in version control

-- Example migration file: 001_create_schemas.sql
CREATE SCHEMA IF NOT EXISTS restaurant_management;
CREATE SCHEMA IF NOT EXISTS ordering_system;
CREATE SCHEMA IF NOT EXISTS analytics;

Performance Considerations

  1. Schema Size: Schemas don’t affect performance directly, but they help organize indexes and statistics
  2. Search Path: Keep the search path short and specific to optimize object resolution
  3. Partitioning: Consider combining schemas with table partitioning for large datasets

Schema Naming Best Practices

Organizing by Function

CREATE SCHEMA auth;        -- Authentication-related objects
CREATE SCHEMA api; -- API-related functions and views
CREATE SCHEMA internal; -- Internal processing logic

This practice is essential because:

  • Improved Maintainability: When all related objects are grouped together, it’s easier to find and modify them. For example, when updating authentication logic, you know all relevant tables and functions are in the auth schema.
  • Better Access Control: You can grant permissions at the schema level. For instance, your API service account might only need access to the api schema, while your authentication service needs access to the auth schema.
  • Clearer Dependencies: It becomes obvious which parts of your application depend on each other. If a service needs access to both auth and api schemas, it might indicate a potential architectural issue.
  • Easier Documentation: You can document entire functional areas together, making it clearer for new team members to understand the system structure.

Multi-tenant Isolation

CREATE SCHEMA tenant_1;
CREATE SCHEMA tenant_2;

-- Each tenant gets their own set of tables
CREATE TABLE tenant_1.users ( /* ... */ );
CREATE TABLE tenant_2.users ( /* ... */ );

This approach is valuable because:

  • Data Isolation: Each tenant’s data is completely separated, reducing the risk of data leaks between tenants
  • Simplified Backup and Restore: You can backup or restore individual tenant data by working with specific schemas
  • Independent Scaling: You can move different tenants to different database servers if needed
  • Custom Extensions: Each tenant can have their own extensions or customizations without affecting others
  • Easier Tenant Management: You can drop or create tenant schemas without affecting other tenants
  • Performance Monitoring: You can track resource usage per tenant by monitoring schema-level statistics

Development & Testing

CREATE SCHEMA production;
CREATE SCHEMA testing;
CREATE SCHEMA development;

This separation is crucial because:

  • Risk Mitigation: Test data and operations can’t accidentally affect production data
  • Environment Parity: You can maintain the same structure across environments while keeping data separate
  • Parallel Development: Multiple developers can work with their own schemas without interference
  • Testing Isolation: Integration tests can run in isolated schemas, preventing test pollution
  • Easy Cleanup: Test data can be easily cleaned up by dropping and recreating schemas
  • Schema Evolution Testing: You can test schema migrations in isolation before applying to production

Versioning and Migration Schemas

CREATE SCHEMA v1;
CREATE SCHEMA v2;
CREATE SCHEMA migration_temp;

This pattern is beneficial because:

  • Zero-downtime Updates: You can prepare new versions of tables and functions without affecting the current version
  • Rollback Capability: If a new version has issues, you can quickly switch back to the previous version
  • Gradual Migration: You can migrate data and functionality piece by piece
  • API Versioning: Different API versions can use different schemas
  • Performance Impact Testing: You can measure the performance impact of schema changes before applying them

Practical Implementation with Go

Let’s implement a simple CRUD application using Go and these schemas. First, we’ll set up our project:

package main

import (
"database/sql"
"fmt"
_ "github.com/lib/pq"
)

type Restaurant struct {
ID int
Name string
Location string
}

// Database connection string
const (
host = "localhost"
port = 5432
user = "postgres"
password = "your_password"
dbname = "restaurant_db"
)

func getDB() (*sql.DB, error) {
connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
return sql.Open("postgres", connStr)
}

Now, let’s implement CRUD operations with schema awareness:

// CreateRestaurant creates a new restaurant in the restaurant_management schema
func CreateRestaurant(db *sql.DB, name, location string) (int, error) {
var id int
err := db.QueryRow(`
INSERT INTO restaurant_management.restaurants (name, location)
VALUES ($1, $2)
RETURNING id
`, name, location).Scan(&id)

return id, err
}

// GetRestaurant retrieves a restaurant by ID
func GetRestaurant(db *sql.DB, id int) (*Restaurant, error) {
restaurant := &Restaurant{}
err := db.QueryRow(`
SELECT id, name, location
FROM restaurant_management.restaurants
WHERE id = $1
`, id).Scan(&restaurant.ID, &restaurant.Name, &restaurant.Location)

if err == sql.ErrNoRows {
return nil, fmt.Errorf("restaurant not found")
}
return restaurant, err
}

// UpdateRestaurant updates restaurant information
func UpdateRestaurant(db *sql.DB, restaurant *Restaurant) error {
result, err := db.Exec(`
UPDATE restaurant_management.restaurants
SET name = $1, location = $2
WHERE id = $3
`, restaurant.Name, restaurant.Location, restaurant.ID)

if err != nil {
return err
}

rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return fmt.Errorf("restaurant not found")
}
return nil
}

// DeleteRestaurant removes a restaurant
func DeleteRestaurant(db *sql.DB, id int) error {
result, err := db.Exec(`
DELETE FROM restaurant_management.restaurants
WHERE id = $1
`, id)

if err != nil {
return err
}

rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return fmt.Errorf("restaurant not found")
}
return nil
}

Let’s see how to use these functions:

func main() {
db, err := getDB()
if err != nil {
panic(err)
}
defer db.Close()

// Create a new restaurant
restaurantID, err := CreateRestaurant(db, "Pizza Palace", "123 Main St")
if err != nil {
panic(err)
}
fmt.Printf("Created restaurant with ID: %d\n", restaurantID)

// Retrieve the restaurant
restaurant, err := GetRestaurant(db, restaurantID)
if err != nil {
panic(err)
}
fmt.Printf("Retrieved restaurant: %+v\n", restaurant)

// Update the restaurant
restaurant.Name = "Super Pizza Palace"
err = UpdateRestaurant(db, restaurant)
if err != nil {
panic(err)
}
fmt.Println("Updated restaurant successfully")

// Delete the restaurant
err = DeleteRestaurant(db, restaurantID)
if err != nil {
panic(err)
}
fmt.Println("Deleted restaurant successfully")
}

Conclusion

PostgreSQL schemas are a powerful feature for organizing your database objects logically. They provide benefits in terms of:

  • Organization and maintainability
  • Security and access control
  • Multi-tenant application support
  • Testing and development workflow

When used properly, schemas can significantly improve your database design and application architecture. Start small with a few logical groupings, and expand your schema organization as your application grows.

Remember: Just like you wouldn’t put all your files in a single folder on your computer, you shouldn’t put all your database objects in a single schema. Use schemas to create a clean, organized, and maintainable database structure.

If you’re interested in diving deeper into system design and backend development, be sure to follow me for more insights, tips, and practical examples. Together, we can explore the intricacies of creating efficient systems, optimizing database performance, and mastering the tools that drive modern applications. Join me on this journey to enhance your skills and stay updated on the latest trends in the tech world! 🚀

Read the design system in bahasa on iniakunhuda.com

--

--

Miftahul Huda
Miftahul Huda

Written by Miftahul Huda

Backend Developer | Exploring Software Architecture

No responses yet