Blink provides efficient database seeding with a clean, declarative syntax.
Example
defmodule MyApp.Seeder do
use Blink
def call do
new()
|> with_table("users")
|> run(MyApp.Repo)
end
def table(_seeder, "users") do
[
%{id: 1, name: "Alice", email: "alice@example.com"},
%{id: 2, name: "Bob", email: "bob@example.com"}
]
end
endOverview
Blink simplifies database seeding by providing a structured way to build and insert rows:
- Create an empty
Seederwithnew/0. - Declare which tables to seed with
with_table/2. - Define
table/2clauses that return the rows to insert. - Run
run/2orrun/3to bulk-insert the rows.
The seeder API
use Blink defines these functions on your module. They are the primary API,
but they do not appear in this module's function list below, because they are
defined on your module rather than on Blink:
with_table(seeder, table_name)andwith_table(seeder, table_name, opts)— declare a table; the rows come from yourtable/2clause for that name.with_context(seeder, key)— declare a context key; the value comes from yourcontext/2clause for that key.
use Blink also imports new/0 and, from this module, put_table/2,3,4,
put_context/2,3, copy_to_table/3,4, from_csv/1,2 and from_json/1,2, so
you call all of them unqualified.
Choosing between with* and put*
Reach for with_table/2 and with_context/2 by default. Because the callback
runs when the table is declared, it receives the seeder built so far and can
read earlier tables and context off it — that is what makes the pipeline
declarative and lets "posts" derive from "users".
Use put_table/3 and put_context/3 only when the data is already in hand at
the call site and no callback is needed:
# Callback form: rows are computed per table, in declaration order
new()
|> with_table("users")
|> with_table("posts")
|> run(MyApp.Repo)
# Direct form: rows already exist
new()
|> put_table("users", users)
|> run(MyApp.Repo)The two forms compose freely — a later table/2 clause reads rows that
put_table/3 added earlier.
Choosing IDs
You assign primary keys yourself. Blink builds plain maps and hands them to
PostgreSQL's COPY, so it never asks the database to generate an ID and never
reads one back. An ID is just another value in the map you are building, no
different from a name or a timestamp — inserting a row is not what gives it
one. So a later table can reference rows that have not been inserted yet:
def table(_seeder, "users") do
[%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}]
end
def table(seeder, "posts") do
Enum.map(seeder.tables["users"], fn user ->
%{id: user.id, title: "Welcome, #{user.name}", user_id: user.id}
end)
endForeign keys are satisfied by insertion order, which follows the order tables were declared, so declare parents before children.
Reset the sequence for serial columns
Inserting explicit IDs does not advance a serial, bigserial, or identity
sequence, so the next ordinary insert your application makes can collide with
a seeded row. See
Getting Started for the reset query.
Seeders
Seeders are the central data unit in Blink. A Seeder is a struct that holds
the rows you want to seed, any contextual data you need during the seeding
process, and internal state that Blink uses to execute the bulk insert.
%Blink.Seeder{
tables: %{
"table_name" => [...]
},
context: %{
"key" => [...]
},
table_order: ...,
table_opts: ...
}All keys in tables must match the name of a table in your database. Table
names can be either atoms or strings.
Tables
A mapping of table names to lists of rows. These rows will be persisted to the
database when run/2 or run/3 is called.
Context
Stores arbitrary data needed during the seeding process. This data is
available when building your seeds but is not inserted into the database by
run/2 or run/3. Use with_context/2 to declare context keys and define
corresponding context/2 clauses.
Custom Logic for Running the Seeder
By default, run/2 and run/3 bulk insert rows from the seeder into the
tables of a Postgres database. Internally they use Postgres' COPY command.
There are two ways to customize the insert behavior:
- Override the default implementation of
run/2orrun/3 - Pass a custom adapter to
run/3(e.g., for non-Postgres databases)
Summary
Callbacks
Builds and returns the data to be stored under a context key in the given
Seeder.
Specifies how to run the Seeder, performing a bulk insert of the seed data
from a Seeder into the given Ecto repository.
Builds and returns the rows to be stored under a table key in the given
Seeder.
Functions
Copies rows into a database table using database-specific bulk copy commands.
Reads a CSV file and returns a list or stream of maps.
Reads a JSON file and returns a list of maps.
Adds several {key, value} pairs to the seeder's context at once.
Adds value to the seeder's context under key.
Adds several {table_name, rows} pairs to the seeder at once.
Adds rows to the seeder under table_name.
Callbacks
@callback context(seeder :: Blink.Seeder.t(), key :: Blink.Seeder.key()) :: Enumerable.t()
Builds and returns the data to be stored under a context key in the given
Seeder.
Called internally by with_context/2. Each key passed to with_context must
have a corresponding context/2 clause.
run/2 and run/3 ignore context data and only insert data from :tables.
When the callback function is missing, an ArgumentError is raised.
@callback run(seeder :: Blink.Seeder.t(), repo :: Ecto.Repo.t()) :: :ok
Specifies how to run the Seeder, performing a bulk insert of the seed data
from a Seeder into the given Ecto repository.
This callback function is optional, since Blink ships with a default implementation.
@callback run(seeder :: Blink.Seeder.t(), repo :: Ecto.Repo.t(), opts :: Keyword.t()) :: :ok
@callback table(seeder :: Blink.Seeder.t(), table_name :: Blink.Seeder.key()) :: Enumerable.t()
Builds and returns the rows to be stored under a table key in the given
Seeder.
Called internally by with_table/2 and with_table/3. Each table name passed
to with_table must have a corresponding table/2 clause.
Data added to a Seeder with table/2 is inserted into the corresponding
database table when calling run/2 or run/3.
The callback can return either a list or a stream of maps. Returning a stream enables memory-efficient seeding of large datasets.
When the callback function is missing, an ArgumentError is raised.
Functions
@spec copy_to_table( rows :: Enumerable.t(), table_name :: String.t(), repo :: Ecto.Repo.t(), opts :: Keyword.t() ) :: :ok
Copies rows into a database table using database-specific bulk copy commands.
Parameters
rows- An enumerable (list or stream) of maps where each map represents a row to insert. All maps must have the same keys, which correspond to the table columns. Using a stream allows for memory-efficient seeding of large datasets.table_name- The name of the table to insert into (string or atom).repo- An Ecto repository module.opts- Keyword list of options::adapter- The adapter module to use. Defaults toBlink.Adapter.Postgres.
All other options are adapter-specific and validated by the adapter — unknown keys raise
ArgumentError. SeeBlink.Adapter.Postgresfor its options::atomic(all-or-nothing copy),:concurrency,:batch_size, and:timeout.
Returns
:ok- When the copy operation succeeds
Raises an exception when the copy operation fails.
Examples
iex> rows = [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}]
iex> copy_to_table(rows, "users", MyApp.Repo)
:ok
# Using a stream for memory-efficient seeding
iex> stream = Stream.map(1..1_000_000, fn i -> %{id: i, name: "User #{i}"} end)
iex> copy_to_table(stream, "users", MyApp.Repo)
:okNotes
The function assumes all rows have the same structure. Column names are extracted from the first row in the enumerable.
Currently only PostgreSQL is supported via Blink.Adapter.Postgres.
@spec from_csv(path :: String.t(), opts :: Keyword.t()) :: Enumerable.t()
Reads a CSV file and returns a list or stream of maps.
Each column header becomes a string key in the resulting maps. All values are returned as strings.
Parameters
path- Path to the CSV file (relative or absolute)opts- Keyword list of options. Unknown options raiseArgumentError::headers-:inferto read the header names from the first row (default), or a list of names for a file without a header row. Explicit headers do not skip the first row — on a file that has one, the header row comes back as a data map.:transform- Function to transform each row map (default: identity):stream- Whentrue, returns a stream instead of a list (default:false)
Examples
# Read CSV with headers in first row
from_csv("users.csv")
# Name the columns of a file that has no header row
from_csv("users_no_headers.csv", headers: ["id", "name", "email"])
# Transform values
from_csv("users.csv", transform: fn row ->
Map.update!(row, "id", &String.to_integer/1)
end)
# Stream for memory-efficient processing
from_csv("large_users.csv", stream: true)Returns
A list of maps, or a stream of maps when stream: true.
Notes
For JSONB columns, prefer leaving the value as the raw JSON string read from
the CSV. Since CSV values are already strings, an untransformed JSONB column is
inserted directly, skipping a JSON round trip. Only decode it into a map (via
:transform) when you need to inspect or modify the value before inserting —
the Postgres adapter will re-encode maps with Jason.encode!/1 on the way in.
Reads a JSON file and returns a list of maps.
The JSON file must contain an array of objects at the root level. Each object becomes a map with string keys.
Parameters
path- Path to the JSON fileopts- Keyword list of options. Unknown options raiseArgumentError::transform- Function to transform each row map (default: identity)
Examples
# Read JSON file
from_json("users.json")
# Transform values
from_json("users.json", transform: fn row ->
Map.update!(row, "id", &String.to_integer/1)
end)Returns
A list of maps.
@spec put_context(seeder :: Blink.Seeder.t(), pairs :: [{Blink.Seeder.key(), any()}]) :: Blink.Seeder.t()
Adds several {key, value} pairs to the seeder's context at once.
A multi-key form of put_context/3; pairs are applied in order and each key
must be unique (as with put_context/3).
Examples
new()
|> put_context(user_id: user_id, project_indices: project_indices)
@spec put_context( seeder :: Blink.Seeder.t(), key :: Blink.Seeder.key(), value :: any() ) :: Blink.Seeder.t()
Adds value to the seeder's context under key.
A convenience wrapper over Blink.Seeder.with_context/3 for when the context
data is already available and you do not want to define a context/2 callback.
Raises ArgumentError if key is already present.
Examples
new()
|> put_context(:generated_at, ~U[2024-01-01 00:00:00Z])
@spec put_table( seeder :: Blink.Seeder.t(), pairs :: [{Blink.Seeder.key(), Enumerable.t()}] ) :: Blink.Seeder.t()
Adds several {table_name, rows} pairs to the seeder at once.
A multi-table form of put_table/3; tables are added in order (which becomes
their insertion order) and each name must be unique. Per-table options are not
supported here — use put_table/4 when you need them.
Examples
new()
|> put_table(users: users, posts: posts)
@spec put_table( seeder :: Blink.Seeder.t(), table_name :: Blink.Seeder.key(), rows :: Enumerable.t(), opts :: Keyword.t() ) :: Blink.Seeder.t()
Adds rows to the seeder under table_name.
A convenience wrapper over Blink.Seeder.with_table/4 for when the rows are
already available and you do not want to define a table/2 callback. rows
may be a list or a stream. opts takes per-table options (for
Blink.Adapter.Postgres: :batch_size and :concurrency), forwarded to
Blink.Seeder.with_table/4. Raises ArgumentError if table_name is
already present.
Examples
new()
|> put_table("users", [%{id: 1, name: "Alice"}])
|> put_table("events", [%{id: 1, name: "Launch"}], batch_size: 1_000)