Architecture

Database Schema

PostgreSQL table structure, indexes, and relationships

This guide documents the complete database schema for ZineCore2, including all tables, indexes, foreign keys, and constraints.

Overview

ZineCore2 uses PostgreSQL 16+ with:

  • Integer primary keys (BigAutoField)
  • ArrayField for repeatable elements
  • Foreign keys for relationships
  • Indexes on external IDs and foreign keys
  • Timestamps on all profile tables

Table Categories

Profile Tables (4)

Core metadata models implementing the four ZineCore2 profiles:

TablePurposeExternal ID Field
catalog_zineZineCore2 bibliographic recordszine_id
agents_agentAgentCore2 authority recordsagent_id
holdings_holdingHoldingCore2 holdings recordsholding_id
repositories_repositoryRepoCore2 institutional recordsrepo_id

Vocabulary Tables (8+)

Controlled vocabularies used by profiles:

TablePurposeUsed By
catalog_subjectSubject termsZine
catalog_genreGenre termsZine
agents_agentkindAgent typesAgent
agents_agentroleAgent rolesAgent
repositories_repositorykindRepository typesRepository
holdings_accessstatusAccess status codesHolding
vocabularies_rightsstatusRights statementsZine
vocabularies_formatProduction formatsZine

Geographic Tables (3)

GeoNames-based geographic data:

TablePurpose
geography_geoplacePlaces (cities, regions, countries)
geography_countryISO 3166 country codes
geography_languageISO 639 language codes

Profile Table Schemas

catalog_zine

ZineCore2 bibliographic records.

CREATE TABLE catalog_zine (
  -- Identifiers
  id BIGSERIAL PRIMARY KEY,
  zine_id VARCHAR(255) UNIQUE NOT NULL,

  -- Required fields
  title VARCHAR(500) NOT NULL,
  creator TEXT[] NOT NULL,
  subject TEXT[] NOT NULL,
  genre TEXT[] NOT NULL,
  date TEXT[] NOT NULL,
  language TEXT[] NOT NULL,
  rights TEXT[] NOT NULL,

  -- Optional fields
  series_title TEXT[],
  issue_designation VARCHAR(100),
  edition_statement TEXT[],
  alternative_title TEXT[],
  contributor TEXT[],
  abstract TEXT,
  table_of_contents TEXT,
  public_notes TEXT[],
  publisher TEXT[],
  physical_dimensions VARCHAR(100),
  number_of_pages VARCHAR(50),
  format TEXT[],
  binding_features TEXT[],
  place_of_publication TEXT[],
  coverage TEXT[],
  source TEXT[],
  relation TEXT[],
  identifier TEXT[],

  -- Timestamps
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Indexes
CREATE UNIQUE INDEX idx_zine_id ON catalog_zine(zine_id);
CREATE INDEX idx_zine_title ON catalog_zine(title);
CREATE INDEX idx_zine_creator ON catalog_zine USING GIN(creator);
CREATE INDEX idx_zine_subject ON catalog_zine USING GIN(subject);
CREATE INDEX idx_zine_genre ON catalog_zine USING GIN(genre);
CREATE INDEX idx_zine_created_at ON catalog_zine(created_at DESC);

Key features:

  • TEXT[] for all repeatable elements (creator, subject, etc.)
  • GIN indexes for fast array searches
  • Unique constraint on zine_id

Storage notes:

  • creator, contributor, publisher store agent IDs (not full objects)
  • subject, genre, rights, format store vocabulary codes (not full terms)
  • Array fields default to [] (empty array) when not provided

agents_agent

AgentCore2 authority records.

CREATE TABLE agents_agent (
  -- Identifiers
  id BIGSERIAL PRIMARY KEY,
  agent_id VARCHAR(255) UNIQUE NOT NULL,

  -- Required fields
  kind VARCHAR(50) NOT NULL,
  display_name VARCHAR(500) NOT NULL,
  public BOOLEAN NOT NULL DEFAULT TRUE,

  -- Optional fields
  legal_name VARCHAR(500),
  alternative_names TEXT[],
  sort_name VARCHAR(500),
  pronouns TEXT[],
  biography TEXT,
  scope_note TEXT[],
  roles TEXT[],
  orcid VARCHAR(255),
  wikidata VARCHAR(50),
  other_identifiers TEXT[],
  active_dates TEXT[],
  location TEXT[],
  website VARCHAR(255),
  email VARCHAR(254),
  social_media TEXT[],

  -- Timestamps
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Indexes
CREATE UNIQUE INDEX idx_agent_id ON agents_agent(agent_id);
CREATE INDEX idx_agent_display_name ON agents_agent(display_name);
CREATE INDEX idx_agent_kind ON agents_agent(kind);
CREATE INDEX idx_agent_public ON agents_agent(public);

Privacy features:

  • public field controls visibility
  • legal_name, email can be kept private
  • Serializers filter based on public field

holdings_holding

HoldingCore2 holdings records.

CREATE TABLE holdings_holding (
  -- Identifiers
  id BIGSERIAL PRIMARY KEY,
  holding_id VARCHAR(255) UNIQUE NOT NULL,

  -- Foreign keys (REQUIRED)
  zine_id BIGINT NOT NULL REFERENCES catalog_zine(id) ON DELETE CASCADE,
  repository_id BIGINT NOT NULL REFERENCES repositories_repository(id) ON DELETE CASCADE,

  -- Optional fields
  call_number VARCHAR(255),
  location VARCHAR(500),
  access_status VARCHAR(100),
  condition TEXT,
  copy_count INTEGER NOT NULL DEFAULT 1,
  barcode VARCHAR(255),
  digital_available BOOLEAN NOT NULL DEFAULT FALSE,
  digital_url VARCHAR(255),
  distro_status VARCHAR(100),
  notes TEXT[],

  -- Timestamps
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Indexes
CREATE UNIQUE INDEX idx_holding_id ON holdings_holding(holding_id);
CREATE INDEX idx_holding_zine ON holdings_holding(zine_id);
CREATE INDEX idx_holding_repository ON holdings_holding(repository_id);
CREATE INDEX idx_holding_zine_repo ON holdings_holding(zine_id, repository_id);
CREATE INDEX idx_holding_access_status ON holdings_holding(access_status);

Foreign key relationships:

  • zine_id → catalog_zine.id (required)
  • repository_id → repositories_repository.id (required)
  • ON DELETE CASCADE — deleting a zine or repository deletes its holdings

Composite index:

  • (zine_id, repository_id) — fast queries like "all holdings of this zine at this repo"

repositories_repository

RepoCore2 institutional records.

CREATE TABLE repositories_repository (
  -- Identifiers
  id BIGSERIAL PRIMARY KEY,
  repo_id VARCHAR(255) UNIQUE NOT NULL,

  -- Required fields
  repository_name VARCHAR(500) NOT NULL,
  repository_kind VARCHAR(100) NOT NULL,
  country VARCHAR(2) NOT NULL,

  -- Optional fields
  alternative_names TEXT[],
  description TEXT,
  scope_note TEXT,
  marc_org_code VARCHAR(20),
  isil VARCHAR(50),
  ror VARCHAR(255),
  city VARCHAR(255),
  region VARCHAR(255),
  postal_code VARCHAR(20),
  website VARCHAR(255),
  email VARCHAR(254),
  phone VARCHAR(50),
  social_media TEXT[],
  holdings_count INTEGER,
  established VARCHAR(10),
  status VARCHAR(50) NOT NULL DEFAULT 'active',

  -- Timestamps
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Indexes
CREATE UNIQUE INDEX idx_repo_id ON repositories_repository(repo_id);
CREATE INDEX idx_repo_name ON repositories_repository(repository_name);
CREATE INDEX idx_repo_kind ON repositories_repository(repository_kind);
CREATE INDEX idx_repo_country ON repositories_repository(country);
CREATE INDEX idx_repo_status ON repositories_repository(status);

No foreign keys — repositories are independent entities.


Vocabulary Table Schema

All vocabularies share the same structure (from BaseVocabulary):

CREATE TABLE catalog_subject (
  id BIGSERIAL PRIMARY KEY,
  code VARCHAR(100) UNIQUE NOT NULL,
  label VARCHAR(255) NOT NULL,
  definition TEXT,
  uri VARCHAR(255) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX idx_subject_code ON catalog_subject(code);
CREATE INDEX idx_subject_label ON catalog_subject(label);

Same structure for:

  • catalog_genre
  • agents_agentkind
  • agents_agentrole
  • repositories_repositorykind
  • holdings_accessstatus
  • vocabularies_rightsstatus
  • vocabularies_format

Vocabulary fields:

  • code — Unique identifier (e.g., feminism)
  • label — Display name (e.g., Feminism)
  • definition — Term definition
  • uri — Full URI (e.g., https://zinecore.org/v2/subjects#feminism)

Geographic Tables

geography_geoplace

GeoNames-based places (cities, regions, countries).

CREATE TABLE geography_geoplace (
  id BIGSERIAL PRIMARY KEY,
  geonames_id INTEGER UNIQUE,
  name VARCHAR(200) NOT NULL,
  ascii_name VARCHAR(200),
  alternate_names TEXT,
  latitude DECIMAL(10, 7),
  longitude DECIMAL(10, 7),
  feature_class VARCHAR(1),
  feature_code VARCHAR(10),
  country_code VARCHAR(2),
  admin1_code VARCHAR(20),
  population BIGINT,
  elevation INTEGER,
  timezone VARCHAR(40),
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX idx_geoplace_geonames ON geography_geoplace(geonames_id);
CREATE INDEX idx_geoplace_name ON geography_geoplace(name);
CREATE INDEX idx_geoplace_country ON geography_geoplace(country_code);
CREATE INDEX idx_geoplace_coords ON geography_geoplace(latitude, longitude);

geography_country

ISO 3166 country codes.

CREATE TABLE geography_country (
  id BIGSERIAL PRIMARY KEY,
  code VARCHAR(2) UNIQUE NOT NULL,
  name VARCHAR(255) NOT NULL,
  official_name VARCHAR(255),
  alpha3_code VARCHAR(3),
  numeric_code VARCHAR(3),
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX idx_country_code ON geography_country(code);
CREATE INDEX idx_country_name ON geography_country(name);

geography_language

ISO 639 language codes.

CREATE TABLE geography_language (
  id BIGSERIAL PRIMARY KEY,
  code VARCHAR(10) UNIQUE NOT NULL,
  name VARCHAR(255) NOT NULL,
  native_name VARCHAR(255),
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX idx_language_code ON geography_language(code);
CREATE INDEX idx_language_name ON geography_language(name);

Relationships Diagram

┌─────────────────────────────────────────────────────────────┐
│                     repositories_repository                 │
│  • id (PK)                                                  │
│  • repo_id (unique)                                         │
│  • repository_name                                          │
└───────────────────────────┬─────────────────────────────────┘
                            │
                            │ FK: repository_id
                            │
┌───────────────────────────▼─────────────────────────────────┐
│                      holdings_holding                       │
│  • id (PK)                                                  │
│  • holding_id (unique)                                      │
│  • zine_id (FK → catalog_zine.id)                          │
│  • repository_id (FK → repositories_repository.id)         │
└───────────────────────────┬─────────────────────────────────┘
                            │
                            │ FK: zine_id
                            │
┌───────────────────────────▼─────────────────────────────────┐
│                       catalog_zine                          │
│  • id (PK)                                                  │
│  • zine_id (unique)                                         │
│  • creator[] (agent IDs)                                    │
│  • contributor[] (agent IDs)                                │
│  • publisher[] (agent IDs)                                  │
│  • subject[] (subject codes)                                │
│  • genre[] (genre codes)                                    │
└─────────────────────────────────────────────────────────────┘
                            │
                            │ References (via creator[])
                            │
┌───────────────────────────▼─────────────────────────────────┐
│                       agents_agent                          │
│  • id (PK)                                                  │
│  • agent_id (unique)                                        │
│  • display_name                                             │
└─────────────────────────────────────────────────────────────┘

Key relationships:

  • Holding → Zine (many-to-one via zine_id)
  • Holding → Repository (many-to-one via repository_id)
  • Zine → Agent (many-to-many via creator[] array)

No join tables — ArrayField stores agent IDs directly in zine table.


Indexes

Primary Key Indexes (Automatic)

All id columns have automatic indexes:

CREATE UNIQUE INDEX catalog_zine_pkey ON catalog_zine(id);
CREATE UNIQUE INDEX agents_agent_pkey ON agents_agent(id);
CREATE UNIQUE INDEX holdings_holding_pkey ON holdings_holding(id);
CREATE UNIQUE INDEX repositories_repository_pkey ON repositories_repository(id);

External ID Indexes (Explicit)

All external IDs are indexed:

CREATE UNIQUE INDEX idx_zine_id ON catalog_zine(zine_id);
CREATE UNIQUE INDEX idx_agent_id ON agents_agent(agent_id);
CREATE UNIQUE INDEX idx_holding_id ON holdings_holding(holding_id);
CREATE UNIQUE INDEX idx_repo_id ON repositories_repository(repo_id);

Purpose: Fast lookups for API requests using external IDs.

Foreign Key Indexes (Explicit)

All foreign keys are indexed:

CREATE INDEX idx_holding_zine ON holdings_holding(zine_id);
CREATE INDEX idx_holding_repository ON holdings_holding(repository_id);
CREATE INDEX idx_holding_zine_repo ON holdings_holding(zine_id, repository_id);

Purpose: Fast joins and cascading deletes.

Array Indexes (GIN)

ArrayField columns use GIN indexes for efficient array searches:

CREATE INDEX idx_zine_creator ON catalog_zine USING GIN(creator);
CREATE INDEX idx_zine_subject ON catalog_zine USING GIN(subject);
CREATE INDEX idx_zine_genre ON catalog_zine USING GIN(genre);

Purpose: Fast queries like "all zines with subject code 'feminism'".

Example query:

SELECT * FROM catalog_zine
WHERE 'feminism' = ANY(subject);

GIN index makes this fast.

Timestamp Indexes

Created timestamps are indexed for sorting:

CREATE INDEX idx_zine_created_at ON catalog_zine(created_at DESC);
CREATE INDEX idx_agent_created_at ON agents_agent(created_at DESC);
CREATE INDEX idx_holding_created_at ON holdings_holding(created_at DESC);
CREATE INDEX idx_repo_created_at ON repositories_repository(created_at DESC);

Purpose: Fast "recent records" queries.


Constraints

Unique Constraints

All external IDs are unique:

ALTER TABLE catalog_zine ADD CONSTRAINT uq_zine_id UNIQUE (zine_id);
ALTER TABLE agents_agent ADD CONSTRAINT uq_agent_id UNIQUE (agent_id);
ALTER TABLE holdings_holding ADD CONSTRAINT uq_holding_id UNIQUE (holding_id);
ALTER TABLE repositories_repository ADD CONSTRAINT uq_repo_id UNIQUE (repo_id);

Vocabulary codes are unique:

ALTER TABLE catalog_subject ADD CONSTRAINT uq_subject_code UNIQUE (code);
ALTER TABLE catalog_genre ADD CONSTRAINT uq_genre_code UNIQUE (code);
-- ... same for all vocabularies

NOT NULL Constraints

Required fields have NOT NULL constraints:

ALTER TABLE catalog_zine ALTER COLUMN title SET NOT NULL;
ALTER TABLE catalog_zine ALTER COLUMN creator SET NOT NULL;
ALTER TABLE catalog_zine ALTER COLUMN subject SET NOT NULL;
-- ... all required fields

Foreign Key Constraints

Holdings must reference valid zine and repository:

ALTER TABLE holdings_holding
  ADD CONSTRAINT fk_holding_zine
  FOREIGN KEY (zine_id) REFERENCES catalog_zine(id) ON DELETE CASCADE;

ALTER TABLE holdings_holding
  ADD CONSTRAINT fk_holding_repository
  FOREIGN KEY (repository_id) REFERENCES repositories_repository(id) ON DELETE CASCADE;

Cascade behavior:

  • Deleting a zine deletes all its holdings
  • Deleting a repository deletes all its holdings

Query Performance

Example 1: Get Zine by External ID

SELECT * FROM catalog_zine
WHERE zine_id = 'zine_mutate_3_1st';

Uses: idx_zine_id (unique index) Performance: O(log n) — very fast

Example 2: Get All Zines by Subject

SELECT * FROM catalog_zine
WHERE 'feminism' = ANY(subject);

Uses: idx_zine_subject (GIN index) Performance: O(log n) — fast

Example 3: Get Holdings for Zine

SELECT h.*, r.repository_name
FROM holdings_holding h
JOIN repositories_repository r ON h.repository_id = r.id
WHERE h.zine_id = 1842;

Uses:

  • idx_holding_zine for WHERE clause
  • Primary key index for join

Performance: O(log n + m) where m = number of holdings

Example 4: Get All Holdings for Repository

SELECT h.*, z.title
FROM holdings_holding h
JOIN catalog_zine z ON h.zine_id = z.id
WHERE h.repository_id = 42;

Uses:

  • idx_holding_repository for WHERE clause
  • Primary key index for join

Performance: O(log n + m)

Example 5: Recent Zines

SELECT * FROM catalog_zine
ORDER BY created_at DESC
LIMIT 25;

Uses: idx_zine_created_at (descending index) Performance: O(1) — index scan + limit


Storage Estimates

Sample Database (100,000 records)

Assumptions:

  • 100,000 zines
  • 50,000 agents
  • 500 repositories
  • 150,000 holdings

Table sizes:

TableRowsAvg Row SizeTotal Size
catalog_zine100,000~2 KB200 MB
agents_agent50,000~1 KB50 MB
holdings_holding150,000~500 bytes75 MB
repositories_repository500~1 KB0.5 MB
Vocabularies~1,000~200 bytes0.2 MB
Geographic~100,000~500 bytes50 MB

Total: ~375 MB

With indexes: ~750 MB (indexes typically 2x table size)

Large deployment (1M zines): ~7.5 GB


Migrations

Django migrations create all tables:

# Generate migrations
python manage.py makemigrations

# Apply migrations
python manage.py migrate

Migration order:

  1. core (base models)
  2. geography (no dependencies)
  3. vocabularies (no dependencies)
  4. agents (no dependencies)
  5. repositories (no dependencies)
  6. catalog (references agents via ArrayField)
  7. holdings (references catalog and repositories)

Backup and Restore

Backup

# Full database dump
pg_dump -U zinecore2 -d zinecore2 > backup.sql

# Schema only
pg_dump -U zinecore2 -d zinecore2 --schema-only > schema.sql

# Data only
pg_dump -U zinecore2 -d zinecore2 --data-only > data.sql

Restore

# Restore full dump
psql -U zinecore2 -d zinecore2 < backup.sql

# Restore schema then data
psql -U zinecore2 -d zinecore2 < schema.sql
psql -U zinecore2 -d zinecore2 < data.sql

Database Maintenance

Analyze Tables (Update Statistics)

ANALYZE catalog_zine;
ANALYZE agents_agent;
ANALYZE holdings_holding;
ANALYZE repositories_repository;

Run after bulk imports.

Reindex

REINDEX TABLE catalog_zine;
REINDEX TABLE agents_agent;

Run if index performance degrades.

Vacuum

VACUUM ANALYZE catalog_zine;
VACUUM ANALYZE agents_agent;

Reclaim space after bulk deletes.


Next Steps

Understanding the database schema? Continue to API Reference to explore all API endpoints.
Copyright ©2026 ZineCore2 Contributors,