Bulk CSV import of users
For initial migration or large-scale onboarding
You have a CSV file with 10,000 users. Import them as Olympus identities.
CSV format
email,first_name,last_name,role,tenant_id,initial_password
alice@example.com,Alice,Smith,admin,t1,TempPass123!
bob@example.com,Bob,Jones,user,t1,
carol@example.com,Carol,Brown,user,t2,email: required.- Other fields: optional.
initial_password: optional. Empty → generate random; user resets via email.
Import script
// scripts/import-users.ts
import { parse } from "csv-parse/sync";
import fs from "fs";
import { kratos } from "./kratos";
const records = parse(fs.readFileSync(process.argv[2], "utf-8"), {
columns: true,
skip_empty_lines: true,
});
const BATCH_SIZE = 5;
const queue = [...records];
async function worker() {
while (queue.length > 0) {
const record = queue.shift()!;
try {
const password = record.initial_password || crypto.randomUUID();
const identity = await kratos.adminCreate({
schema_id: "default",
traits: {
email: record.email,
first_name: record.first_name,
last_name: record.last_name,
tenant_id: record.tenant_id,
role: record.role,
},
credentials: {
password: { config: { password } },
},
state: "active",
});
console.log(`✓ ${record.email}`);
} catch (err: any) {
console.error(`✗ ${record.email}: ${err.message}`);
}
}
}
await Promise.all(Array.from({ length: BATCH_SIZE }, worker));Run:
bun run scripts/import-users.ts users.csvPre-validation
Before importing, validate:
function validate(record): string[] {
const errors: string[] = [];
if (!record.email) errors.push("missing_email");
if (!record.email?.match(/.+@.+\..+/)) errors.push("invalid_email");
if (record.role && !["admin", "user", "support"].includes(record.role)) {
errors.push("invalid_role");
}
if (record.tenant_id && !tenantExists(record.tenant_id)) {
errors.push("unknown_tenant");
}
return errors;
}
// Dry-run first
const errors = records.flatMap(r => validate(r).map(e => `${r.email}: ${e}`));
if (errors.length > 0) {
console.error(`${errors.length} validation errors:`);
errors.forEach(e => console.log(e));
process.exit(1);
}Fail fast, not after 5,000 successes.
Duplicate handling
User already exists?
try {
await kratos.adminCreate(...);
} catch (err) {
if (err.code === "conflict") {
if (UPDATE_EXISTING) {
// Find by email, update
const existing = await kratos.adminList({ filter: `traits.email eq "${record.email}"` });
await kratos.adminPatch(existing[0].id, [
{ op: "replace", path: "/traits/role", value: record.role },
]);
} else {
console.log(`Skipping (exists): ${record.email}`);
}
}
}Decide: update or skip on collision.
Recovery emails
For users without passwords, send recovery:
const recoveryUrl = await kratos.adminCreateRecoveryLink({ identity_id: identity.id });
await sendEmail(record.email, "Welcome to [Your App]", `
Hi ${record.first_name},
An account was created for you. Set your password:
${recoveryUrl}
This link expires in 1 hour.
`);Batch send. Rate-limit your ESP.
Progress reporting
For large imports:
let done = 0;
const total = records.length;
const startTime = Date.now();
async function worker() {
while (queue.length > 0) {
const record = queue.shift()!;
try {
await createUser(record);
done++;
if (done % 100 === 0) {
const rate = done / ((Date.now() - startTime) / 1000);
const eta = (total - done) / rate;
console.log(`${done}/${total} (${rate.toFixed(1)}/s, ETA: ${eta.toFixed(0)}s)`);
}
} catch (err) { /* log */ }
}
}Real-time visibility.
Resumability
If import dies halfway:
// Mark imported records
const imported = new Set<string>();
fs.readFileSync(".import-progress").split("\n").forEach(line => imported.add(line));
async function process(record) {
if (imported.has(record.email)) {
console.log(`Skip (done): ${record.email}`);
return;
}
await createUser(record);
fs.appendFileSync(".import-progress", `${record.email}\n`);
}Re-run picks up where it left off.
Notification batching
10k recovery emails will flood your ESP. Stagger:
const RECOVERY_RATE = 60; // 60 emails / minute
let sent = 0;
for (const record of users) {
await sendRecoveryEmail(record);
sent++;
if (sent % RECOVERY_RATE === 0) {
console.log(`Sent ${sent} emails. Sleeping 1 min...`);
await sleep(60_000);
}
}Most ESPs allow 100-500/s, but you don't want to look like spam.
Audit
Each user creation logged:
audit({
event: "user_imported",
target: identity.id,
metadata: { source: "csv_import", admin: actorId },
});For DSR, easier to trace origin.
Common errors
Email collision
User exists. Update or skip (your choice).
Invalid schema
Trait doesn't match schema. Validate upfront.
Permission denied
Admin endpoint requires admin token. Verify your auth.
Rate limit
Kratos / Postgres can throttle under high concurrency. Lower BATCH_SIZE.
CSV file expectations
- UTF-8 encoded.
- LF line endings (not CRLF).
- Quoted strings that contain commas.
- Header row.
email,first_name,last_name,description
"alice@example.com","Alice","Smith","Special user"Use a CSV parser, don't split by comma manually.