{
  "slug": "Database-Seeder-Implementation-in-the-Go-Wordpress-project-b09ce0a4728d",
  "title": "Database Seeder Implementation in the Go-Wordpress project",
  "subtitle": "Overview",
  "excerpt": "Overview",
  "date": "2026-02-23",
  "tags": [
    "Go"
  ],
  "readingTime": "7 min",
  "url": "https://medium.com/@mobinshaterian/database-seeder-implementation-in-the-go-wordpress-project-b09ce0a4728d",
  "hero": "https://cdn-images-1.medium.com/max/800/1*UFtuF0Wm1OBApDaDZr2-mw.png",
  "content": [
    {
      "type": "heading",
      "level": 2,
      "text": "Overview"
    },
    {
      "type": "paragraph",
      "html": "This article documents the design and implementation of a database seeder added to the <a href=\"https://github.com/mobintmu/go-wordpress\" target=\"_blank\" rel=\"noreferrer noopener\">go-wordpress</a> project — a production-ready Go web service blueprint built on Clean Architecture, Uber FX, Gin, and SQLC."
    },
    {
      "type": "paragraph",
      "html": "The seeder system provides a safe, idempotent mechanism to populate initial data (websites and scraper configurations) on application startup, running automatically after database migrations without duplicating records across restarts."
    },
    {
      "type": "image",
      "src": "https://cdn-images-1.medium.com/max/800/1*UFtuF0Wm1OBApDaDZr2-mw.png",
      "alt": "Database Seeder Implementation in the Go-Wordpress project",
      "caption": "",
      "width": 2752,
      "height": 1536
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Go-Wordpress"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Git commit"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Why Keep Seeds Separate from Migrations"
    },
    {
      "type": "paragraph",
      "html": "A common question is whether to place initial data inside migration SQL files. This implementation intentionally separates them for three reasons:"
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "<strong>Migrations are structural</strong> — they define schema changes, run once, and are versioned. Mixing data into them pollutes migration history and makes rollbacks painful.",
        "<strong>Seeds are data</strong> — they should be idempotent, re-runnable, and potentially environment-specific. A dedicated seeder system makes this possible.",
        "<strong>Consistency</strong> — the pattern mirrors how <code>golang-migrate</code> tracks applied migrations in <code>schema_migrations</code>, applying the same concept to data via a <code>seed_history</code> table."
      ]
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Folder Structure"
    },
    {
      "type": "paragraph",
      "html": "The following directories were added or modified:"
    },
    {
      "type": "code",
      "lang": "text",
      "code": "internal/\n└── storage/\n    └── sql/\n        ├── migrations/          ← schema only (existing)\n        │   ├── 001_create_websites.up.sql\n        │   └── 002_create_configs.up.sql\n        ├── seeds/               ← data only (new)\n        │   ├── 001_seed_websites.sql\n        │   └── 002_seed_configs.sql\n        └── migrate/\n            ├── runner.go        ← existing migration runner\n            └── seeder.go        ← new seeder"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "The Seeder — seeder.go"
    },
    {
      "type": "paragraph",
      "html": "The seeder lives in the same package as the migration runner (<code>internal/storage/sql/migrate</code>) and follows the same structural pattern as <code>RunMigrations</code>."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Struct & Constructor"
    },
    {
      "type": "paragraph",
      "html": "The <code>Seeder</code> holds a <code>*sql.DB</code> reference. Because Uber FX registers the database connection under the <code>sqlc.DBTX</code> interface, the constructor accepts that interface and type-asserts it to <code>*sql.DB</code> at runtime — the underlying type is always <code>*sql.DB</code>, so this is safe:"
    },
    {
      "type": "code",
      "lang": "go",
      "code": "type Seeder struct {\n    db *sql.DB\n}\nfunc NewSeeder(dbtx sqlc.DBTX) *Seeder {\n    db, ok := dbtx.(*sql.DB)\n    if !ok {\n        log.Fatal(\"seeder requires *sql.DB, got incompatible type\")\n    }\n    return &Seeder{\n        db: db\n    }\n}"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "FX Invoke Function"
    },
    {
      "type": "paragraph",
      "html": "<code>RunSeeder</code> mirrors <code>RunMigrations</code> exactly — it is skipped in test environments and calls <code>panic</code> on error to halt startup cleanly:"
    },
    {
      "type": "code",
      "lang": "go",
      "code": "func RunSeeder(seeder *Seeder, cfg *config.Config) {\n    if cfg.IsTest() {\n        return\n    }\n    if err := seeder.SeederRun();\n    err != nil {\n        panic(err)\n    }\n}"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "The seed_history Table"
    },
    {
      "type": "paragraph",
      "html": "On every startup, the seeder creates (if not exist) a tracking table:"
    },
    {
      "type": "code",
      "lang": "sql",
      "code": "CREATE TABLE IF NOT EXISTS seed_history (\n    id SERIAL PRIMARY KEY,\n    seed_name TEXT NOT NULL UNIQUE,\n    applied_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL\n);"
    },
    {
      "type": "paragraph",
      "html": "Before executing any seed file, the seeder queries this table. If the seed name already exists, it is skipped. This makes the system safe to invoke on every application startup."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Execution Flow"
    },
    {
      "type": "paragraph",
      "html": "For each&nbsp;<code>.sql</code> file found in <code>internal/storage/sql/seeds</code> (sorted by filename):"
    },
    {
      "type": "list",
      "ordered": true,
      "items": [
        "Check <code>seed_history</code> — skip if already applied",
        "Read the SQL file from disk using <code>os.ReadFile</code>",
        "Open a database transaction",
        "Execute the seed SQL inside the transaction",
        "Insert the seed name into <code>seed_history</code> inside the same transaction",
        "Commit — both the data insert and the history record succeed or fail together"
      ]
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Full Implementation"
    },
    {
      "type": "code",
      "lang": "go",
      "code": "package migrate\nimport (\n\"database/sql\"\n\"fmt\"\n\"go-wordpress/internal/storage/sql/sqlc\"\n\"log\"\n\"os\"\n\"sort\"\n\"strings\"\n)\ntype Seeder struct {\n    db *sql.DB\n}\nfunc NewSeeder(dbtx sqlc.DBTX) *Seeder {\n    db, ok := dbtx.(*sql.DB)\n    if !ok {\n        log.Fatal(\"seeder requires *sql.DB, got incompatible type\")\n    }\n    return &Seeder{\n        db: db\n    }\n}\nfunc RunSeeder(seeder *Seeder, cfg *config.Config) {\n    if cfg.IsTest() {\n        return\n    }\n    if err := seeder.SeederRun();\n    err != nil {\n        panic(err)\n    }\n}\nfunc (s *Seeder) SeederRun() error {\n    _, err :=\n    s.db.Exec(`        CREATE TABLE IF NOT EXISTS seed_history (            id         SERIAL PRIMARY KEY,            seed_name  TEXT NOT NULL UNIQUE,            applied_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL        )    `)\n    if err != nil {\n        return fmt.Errorf(\"failed to create seed_history table: %w\", err)\n    }\n    entries, err := os.ReadDir(\"internal/storage/sql/seeds\")\n    if err != nil {\n        return fmt.Errorf(\"failed to read seeds directory: %w\", err)\n    }\n    sort.Slice(entries, func(i, j int) bool {\n        return entries[i].Name() < entries[j].Name()\n    })\n    for _, entry := range entries {\n        if !strings.HasSuffix(entry.Name(), \".sql\") {\n            continue\n        }\n        seedName := entry.Name(\n        )\n        var exists bool err :=\n        s.db.QueryRow(\"SELECT EXISTS(SELECT 1 FROM seed_history WHERE seed_name = $1)\",\n        seedName,).Scan(&exists) if err != nil {\n            return fmt.Errorf(\"failed to check seed history for %s: %w\", seedName, err)\n        }\n        if exists {\n            log.Printf(\"⏭️  Seed already applied, skipping: %s\", seedName) continue\n        }\n        content, err := os.ReadFile(\"internal/storage/sql/seeds/\" + seedName) if err != nil {\n            return fmt.Errorf(\"failed to read seed file %s: %w\", seedName, err)\n        }\n        tx, err := s.db.Begin() if err != nil {\n            return fmt.Errorf(\"failed to begin transaction for %s: %w\", seedName, err)\n        }\n        if _, err := tx.Exec(string(content));\n        err != nil {\n            tx.Rollback() return fmt.Errorf(\"failed to execute seed %s: %w\", seedName, err)\n        }\n        if _, err := tx.Exec(\"INSERT INTO seed_history (seed_name) VALUES ($1)\", seedName,);\n        err != nil {\n            tx.Rollback() return fmt.Errorf(\"failed to record seed %s: %w\", seedName, err)\n        }\n        if err := tx.Commit();\n        err != nil {\n            return fmt.Errorf(\"failed to commit seed %s: %w\", seedName, err)\n        }\n        log.Printf(\"✅ Seed applied: %s\", seedName)\n    }\n    log.Println(\"✅ All seeds applied successfully\")\n    return nil\n}"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Why os.ReadFile Instead of embed"
    },
    {
      "type": "paragraph",
      "html": "The <code>//go:embed</code> directive does not allow&nbsp;<code>..</code> in paths, which would be required given the package location. Since the binary is always run from the project root (<code>go run cmd/server/main.go</code>), <code>os.ReadFile</code> with a relative path is consistent with how the existing migration runner references its files using <code>file://internal/storage/sql/migrations</code>."
    },
    {
      "type": "embed",
      "provider": "youtube",
      "url": "https://www.youtube.com/embed/scLVXF3B4BQ?feature=oembed"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Uber FX Wiring — app.go"
    },
    {
      "type": "paragraph",
      "html": "The seeder is registered in the FX container in <code>NewApp()</code>. Two things are required: providing the <code>Seeder</code> via <code>fx.Provide</code>, and invoking <code>RunSeeder</code> via <code>fx.Invoke</code> after <code>RunMigrations</code>. FX processes <code>Invoke</code> calls in declaration order, guaranteeing migrations run before seeding:"
    },
    {
      "type": "code",
      "lang": "go",
      "code": "fx.Provide(    migrate.NewRunner,  // migration runner    migrate.NewSeeder,  // seeder\n...),fx.Invoke(    migrate.RunMigrations,  // schema first    migrate.RunSeeder,      // data second\n...),"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Seed SQL Files"
    },
    {
      "type": "heading",
      "level": 3,
      "text": "001_seed_websites.sql"
    },
    {
      "type": "paragraph",
      "html": "Inserts two websites. The <code>ON CONFLICT (domain) DO NOTHING</code> clause uses the <code>UNIQUE</code> constraint on the <code>domain</code> column as a second safety net, independent of <code>seed_history</code>:"
    },
    {
      "type": "code",
      "lang": "sql",
      "code": "INSERT INTO websites (name, domain, status, created_at, updated_at)\nVALUES\n    ('hosseinibrothers', 'hosseinibrothers.ir', 'active', NOW(), NOW()),\n    ('badomjip', 'badomjip.com', 'active', NOW(), NOW())\nON CONFLICT (domain) DO NOTHING;"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "002_seed_configs.sql"
    },
    {
      "type": "paragraph",
      "html": "Inserts scraper configuration JSON for each website. Rather than hardcoding integer IDs, a <code>SELECT&nbsp;... FROM websites WHERE domain =&nbsp;...</code> subquery resolves the correct <code>website_id</code> dynamically. This means seed order matters — websites must be seeded before configs, which the numeric filename prefix (<code>001_</code>, <code>002_</code>) guarantees."
    },
    {
      "type": "paragraph",
      "html": "Each config record stores a full JSON scraper definition under the key <code>scraper_config</code>, including:"
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "<code>start_url</code> — the entry point for the scraper",
        "<code>product_list</code> — CSS selectors for listing pages (item container, title, price, link, image)",
        "<code>pagination</code> — selector for the next-page link",
        "<code>product_detail</code> — selectors for detail page content (description)"
      ]
    },
    {
      "type": "paragraph",
      "html": "The&nbsp;<code>::json</code> cast ensures PostgreSQL stores the value in the JSON column with proper type validation."
    },
    {
      "type": "code",
      "lang": "sql",
      "code": "INSERT INTO configs (\n    website_id,\n    key,\n    value,\n    status,\n    created_at,\n    updated_at\n)\nSELECT\n    w.id,\n    'scraper_config',\n    '{\n        \"domain\": \"badomjip.com\",\n        \"start_url\": \"https://badomjip.com/\",\n        \"product_list\": {\n            \"item\": \"div.product-grid-item\",\n            \"fields\": {\n                \"title\": {\n                    \"selector\": \"h3.wd-entities-title a\",\n                    \"attr\": \"\"\n                },\n                \"price\": {\n                    \"selector\": \"span.price\",\n                    \"attr\": \"\"\n                },\n                \"link\": {\n                    \"selector\": \"a.product-image-link\",\n                    \"attr\": \"href\"\n                },\n                \"image\": {\n                    \"selector\": \"a.product-image-link img\",\n                    \"attr\": \"src\"\n                }\n            }\n        },\n        \"pagination\": {\n            \"next\": \"a.next.page-numbers\"\n        },\n        \"product_detail\": {\n            \"description\": {\n                \"selector\": \"div.woocommerce-product-details__short-description, div.woocommerce-Tabs-panel--description\"\n            }\n        }\n    }'::json,\n    'active',\n    NOW(),\n    NOW()\nFROM websites AS w\nWHERE w.domain = 'badomjip.com'\nON CONFLICT DO NOTHING;\n\nINSERT INTO configs (\n    website_id,\n    key,\n    value,\n    status,\n    created_at,\n    updated_at\n)\nSELECT\n    w.id,\n    'scraper_config',\n    '{\n        \"domain\": \"hosseinibrothers.ir\",\n        \"start_url\": \"https://hosseinibrothers.ir/\",\n        \"product_list\": {\n            \"item\": \"div.js-product-miniature\",\n            \"fields\": {\n                \"title\": {\n                    \"selector\": \"a.stsb_mini_product_name\",\n                    \"attr\": \"\"\n                },\n                \"price\": {\n                    \"selector\": \"div.stsb_pm_price\",\n                    \"attr\": \"\"\n                },\n                \"link\": {\n                    \"selector\": \"a.stsb_mini_product_name\",\n                    \"attr\": \"href\"\n                },\n                \"image\": {\n                    \"selector\": \"img.stsb_pm_image\",\n                    \"attr\": \"src\"\n                }\n            }\n        },\n        \"pagination\": {\n            \"next\": \"a[rel=''next'']\"\n        },\n        \"product_detail\": {\n            \"description\": {\n                \"selector\": \"div.stsb_pro_summary p, div.stsb_read_more_box p\"\n            }\n        }\n    }'::json,\n    'active',\n    NOW(),\n    NOW()\nFROM websites AS w\nWHERE w.domain = 'hosseinibrothers.ir'\nON CONFLICT DO NOTHING;"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Key Design Decisions"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Idempotency at Two Levels"
    },
    {
      "type": "paragraph",
      "html": "Seeds are protected against duplication at both the application level (<code>seed_history</code> table check before execution) and the database level (<code>ON CONFLICT</code> clauses in the SQL itself). Even if <code>seed_history</code> were dropped manually. rerunning seeds would not create duplicate rows."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Transactional Integrity"
    },
    {
      "type": "paragraph",
      "html": "Each seed file runs inside a single transaction that also includes the <code>INSERT</code> into <code>seed_history</code>. A partial failure — where data inserts but the history record fails, or vice versa — is impossible. Either both succeed or both are rolled back."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Ordered Execution"
    },
    {
      "type": "paragraph",
      "html": "Seed files are sorted alphabetically before execution. The numeric prefix convention (<code>001_</code>, <code>002_</code>) guarantees that dependency order is respected — websites are always seeded before configs that reference them by <code>website_id</code>."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Test Safety"
    },
    {
      "type": "paragraph",
      "html": "The <code>IsTest()</code> Check matches the pattern already used by <code>RunMigrations</code>. Neither migrations nor seeds run during automated tests, keeping the test database in a predictable state controlled entirely by the test setup."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Dynamic ID Resolution"
    },
    {
      "type": "paragraph",
      "html": "Config seeds reference websites by <code>domain</code> rather than by a hardcoded integer ID. This makes seeds portable across environments where auto-increment IDs may differ, and removes the fragile assumption that website ID 1 is always <code>badomjip.com</code>."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Conclusion"
    },
    {
      "type": "paragraph",
      "html": "The seeder implementation adds production-grade initial data loading to go-wordpress with minimal complexity. It integrates naturally into the existing FX dependency graph, follows the same lifecycle patterns as the migration runner, and provides strong guarantees against duplicate data through layered idempotency."
    },
    {
      "type": "paragraph",
      "html": "Adding new seed data in the future requires only creating a new numbered&nbsp;<code>.sql</code> file in <code>internal/storage/sql/seeds/</code> — no Go code changes needed."
    }
  ]
}
