Recipe: bulk-import performers from a spreadsheet (idempotent upsert)

Recipe: bulk-import performers from a spreadsheet (idempotent upsert)

Recipe: bulk-import performers from a spreadsheet (idempotent upsert)

💡

Prompt example

Import every row of "Ideas 2026 Speakers to Nomad.xlsx" into the performer table: create a performer per row with the correct slug format and PerformerType set to "Speaker", and make a re-run update the same records instead of creating duplicates.

Reference entities like Performers follow a simpler import contract than Events - no
datetime conversion, no live schedule - but the idempotency, slug format, and
reference resolution patterns are exactly the same. This recipe covers the complete
flow: discover the entity's fields and existing slug conventions, resolve
the PerformerType lookup once, dry-run one row to validate the payload, then
idempotently upsert every row keyed on slug.

Safety (Class C). Performers are global account objects with no folder anchor.
Tear down anything you create with delete_content(sdk, id, PERFORMER_CD).
Production imports must be a human-reviewed, explicitly-approved action.

Before you write: discover first

Run these steps before building any payload. They take ~2 minutes and prevent silent
mismatches that are hard to diagnose after a 400-row import.

1. Confirm the content definition fields

cd = sdk.get_content_definition(PERFORMER_CD)
print([f["properties"]["propertyName"] for f in cd["contentFields"]])
# e.g. ['Name', 'Slug', 'PerformerType', 'Bio', ...]

This confirms the exact propertyName values to use as write keys (camelCase in the
payload - first-letter-lower the CD names: Namename, PerformerTypeperformerType).

2. Inspect existing slug format

Search a sample of existing performers and read their identifiers.slug:

flt = [{"fieldName": "contentDefinitionId", "operator": "Equals", "values": PERFORMER_CD}]
sample = sdk.search(None, 0, 5, flt, None, None, None, None, None, None, None, None, None, None, None)
for item in sample["items"]:
    print(item["identifiers"].get("slug"))
# e.g. landrieu-mitch, burns-ken, jackson-greg

Nomad stores performer slugs as lastname-firstname (all lowercase, hyphen-separated).
Spreadsheets frequently use Firstname-Lastname (title-case). Transform before importing -
see slug helper below.

3. Enumerate PerformerType lookup values

Search the PerformerType CD with only the CD filter to get all values and their IDs:

flt = [{"fieldName": "contentDefinitionId", "operator": "Equals", "values": PERFORMER_TYPE_CD}]
res = sdk.search(None, 0, 50, flt, None, None, None, None, None, None, None, None, None, None, None)
for item in res["items"]:
    ids = item.get("identifiers", {})
    print(ids.get("name"), "->", item["id"])
# Speaker -> 033ed0e6-4189-4dcd-91e3-89efbcf64ec9
# Actor   -> ...

Build a name → {id, description} map once and reuse it for every row.

Admin credentials

Credentials live in AWS Secrets Manager under nomad/<prefix>/adminUserCredentials.
Fetch them with:

aws secretsmanager get-secret-value \
    --secret-id nomad/<prefix>/adminUserCredentials \
    --region <region>

Returns {"username": "[email protected]", "password": "..."}. Use these to
initialize the SDK - never hardcode them in the script.

Slug transformation

Spreadsheet slugs are typically Firstname-Lastname (title-case). The platform stores
lastname-firstname (lowercase). Transform each row before writing:

import re

def name_to_slug(name: str) -> str:
    # Strip suffixes like ", Jr." or " Sr."
    name = re.sub(r",?\s+(jr\.?|sr\.?|ii|iii|iv|v)\.?\s*$", "", name, flags=re.IGNORECASE).strip()
    parts = name.split()
    if len(parts) == 1:
        return parts[0].lower()
    last, first_parts = parts[-1], parts[:-1]
    slug = last + "-" + "-".join(first_parts)
    # lowercase, strip periods and apostrophes, normalize hyphens
    return slug.lower().replace(".", "").replace("'", "").replace("‑", "-")

Run this transformation and deduplicate by slug before importing. If two rows produce
the same slug after transformation, fail-closed before any write.

The writes per row

Each row is the standard two-step content contract:

  1. create_content(PERFORMER_CD, None) - only when the slug is new. Returns
    {"contentId": "..."}. Keep the contentId - update_content returns None
    even on success.
  2. update_content(content_id, PERFORMER_CD, props, None) - one properties-only
    patch: name, slug, performerType (required ref). All four arguments are
    positional - pass None for language_id. Keyword arguments raise
    missing required positional argument.

Casing note. The CD reports propertyName in PascalCase (Name, Slug,
PerformerType), but write keys are camelCase (name, slug, performerType).
Write camelCase; first-letter-lower the CD names before comparing.

Mapping plan

Spreadsheet columnDestination (write)How it is resolved
Nameperformer.nameverbatim
Slugperformer.slug - idempotency keytransformed to lastname-firstname lowercase
Role / (constant)performer.performerType (required ref)name → {id, description} via PerformerType CD, resolved once before the loop

Python

# Components: get_content_definition, search, create_content, update_content, delete_content
import re
import sys
import pandas as pd

sys.path.insert(0, r"C:\Projects\GitHub\test-suite")
from nomad_media_pip.src.nomad_sdk import Nomad_SDK

PERFORMER_CD      = "33cec5ca-6170-4237-842b-78bf1ef17932"
PERFORMER_TYPE_CD = "3d4c1ffa-d7be-4ad8-ac91-7aaf03a5c6f1"

def name_to_slug(name):
    name = name.replace("‑", "-")  # normalize non-breaking hyphens
    name = re.sub(r",?\s+(jr\.?|sr\.?|ii|iii|iv|v)\.?\s*$", "", name, flags=re.IGNORECASE).strip()
    parts = name.split()
    if len(parts) == 1:
        return parts[0].lower()
    return (parts[-1] + "-" + "-".join(parts[:-1])).lower().replace(".", "").replace("'", "")

def assert_unique_slugs(rows):
    seen, dupes = set(), set()
    for r in rows:
        slug = r["slug"]
        (dupes if slug in seen else seen).add(slug)
    if dupes:
        raise ValueError(f"Duplicate slugs in spreadsheet (aborting): {sorted(dupes)}")

def resolve_performer_type(sdk, name):
    """Resolve a PerformerType name to {id, description}; raise if not found."""
    flt = [
        {"fieldName": "contentDefinitionId", "operator": "Equals", "values": PERFORMER_TYPE_CD},
        {"fieldName": "name",                "operator": "Equals", "values": name},
    ]
    res = sdk.search(None, 0, 1, flt, None, None, None, None, None, None, None, None, None, None, None)
    items = (res or {}).get("items", [])
    if not items:
        raise ValueError(f"PerformerType '{name}' not found on this deployment")
    return {"id": items[0]["id"], "description": name}

def find_by_slug(sdk, slug):
    flt = [
        {"fieldName": "contentDefinitionId", "operator": "Equals", "values": PERFORMER_CD},
        {"fieldName": "slug",                "operator": "Equals", "values": slug},
    ]
    items = (sdk.search(None, 0, 1, flt, None, None, None, None, None, None, None, None, None, None, None) or {}).get("items", [])
    return items[0]["id"] if items else None

def upsert_performer(sdk, row, performer_type_ref, dry_run=False):
    props = {"name": row["name"], "slug": row["slug"], "performerType": performer_type_ref}
    if dry_run:
        existing = find_by_slug(sdk, row["slug"])
        print(f"  DRY-RUN [{'would-update' if existing else 'would-create'}] {row['name']} | {row['slug']}")
        return existing, "dry-run"
    existing_id = find_by_slug(sdk, row["slug"])
    if existing_id:
        sdk.update_content(existing_id, PERFORMER_CD, props, None)
        return existing_id, "updated"
    result = sdk.create_content(PERFORMER_CD, None)
    content_id = result["contentId"]
    sdk.update_content(content_id, PERFORMER_CD, props, None)
    return content_id, "created"

def run_import(sdk, spreadsheet_path, performer_type_name="Speaker", dry_run=False):
    df = pd.read_excel(spreadsheet_path)
    rows = [{"name": str(r["Name"]).strip(), "slug": name_to_slug(str(r["Name"]).strip())}
            for _, r in df.iterrows() if str(r["Name"]).strip()]
    assert_unique_slugs(rows)

    # Discover field names and confirm CD is reachable
    cd = sdk.get_content_definition(PERFORMER_CD)
    print("Performer fields:", [f["properties"]["propertyName"] for f in cd["contentFields"]])

    # Resolve PerformerType once
    performer_type_ref = resolve_performer_type(sdk, performer_type_name)
    print(f"PerformerType '{performer_type_name}': {performer_type_ref['id']}")

    # Dry-run first 3 rows
    print(f"\nDry-run preview (first 3 rows):")
    for row in rows[:3]:
        upsert_performer(sdk, row, performer_type_ref, dry_run=True)

    if dry_run:
        print(f"\n  ... and {len(rows) - 3} more rows would be processed.")
        return

    created, updated, errors = 0, 0, []
    for i, row in enumerate(rows):
        try:
            _, action = upsert_performer(sdk, row, performer_type_ref)
            if action == "created":
                created += 1
            else:
                updated += 1
            if (i + 1) % 50 == 0:
                print(f"  Progress: {i+1}/{len(rows)} (created={created}, updated={updated})")
        except Exception as e:
            errors.append({"name": row["name"], "slug": row["slug"], "error": str(e)})
            print(f"  ERROR: {row['name']} ({row['slug']}): {e}")

    print(f"\nImport complete: {created} created, {updated} updated, {len(errors)} errors.")
    if errors:
        for err in errors:
            print(f"  FAILED: {err['name']} ({err['slug']}): {err['error']}")

    # Sample verify
    print("\nVerifying sample (first 10 + last 10)...")
    ok = 0
    for row in rows[:10] + rows[-10:]:
        flt = [
            {"fieldName": "contentDefinitionId", "operator": "Equals", "values": PERFORMER_CD},
            {"fieldName": "slug",                "operator": "Equals", "values": row["slug"]},
        ]
        items = (sdk.search(None, 0, 1, flt, None, None, None, None, None, None, None, None, None, None, None) or {}).get("items", [])
        if items and items[0].get("identifiers", {}).get("name") == row["name"]:
            ok += 1
        else:
            print(f"  DIFF/MISSING: {row['name']} ({row['slug']})")
    print(f"Sample verify: {ok}/20 OK.")

JavaScript

Same flow; parse the workbook with exceljs or xlsx, then resolve/upsert exactly
as in Python. The reference shapes and the write sequence are identical.

// Components: getContentDefinition, search, createContent, updateContent, deleteContent
const PERFORMER_CD      = "33cec5ca-6170-4237-842b-78bf1ef17932";
const PERFORMER_TYPE_CD = "3d4c1ffa-d7be-4ad8-ac91-7aaf03a5c6f1";

function nameToSlug(name) {
    name = name.replace(/‑/g, "-")
               .replace(/,?\s+(jr\.?|sr\.?|ii|iii|iv|v)\.?\s*$/i, "").trim();
    const parts = name.split(/\s+/);
    if (parts.length === 1) return parts[0].toLowerCase();
    const last = parts[parts.length - 1];
    const first = parts.slice(0, -1);
    return (last + "-" + first.join("-")).toLowerCase().replace(/\./g, "").replace(/'/g, "");
}

function assertUniqueSlugs(rows) {
    const seen = new Set(), dupes = new Set();
    for (const r of rows) {
        if (seen.has(r.slug)) dupes.add(r.slug); else seen.add(r.slug);
    }
    if (dupes.size) throw new Error(`Duplicate slugs (aborting): ${[...dupes].join(", ")}`);
}

async function resolvePerformerType(sdk, name) {
    const flt = [
        { fieldName: "contentDefinitionId", operator: "Equals", values: PERFORMER_TYPE_CD },
        { fieldName: "name",                operator: "Equals", values: name },
    ];
    const items = ((await sdk.search(null, 0, 1, flt, null, null, null, null, null, null, null, null, null, null, null)) || {}).items || [];
    if (!items.length) throw new Error(`PerformerType '${name}' not found on this deployment`);
    return { id: items[0].id, description: name };
}

async function findBySlug(sdk, slug) {
    const flt = [
        { fieldName: "contentDefinitionId", operator: "Equals", values: PERFORMER_CD },
        { fieldName: "slug",                operator: "Equals", values: slug },
    ];
    const items = ((await sdk.search(null, 0, 1, flt, null, null, null, null, null, null, null, null, null, null, null)) || {}).items || [];
    return items.length ? items[0].id : null;
}

async function upsertPerformer(sdk, row, performerTypeRef) {
    const props = { name: row.name, slug: row.slug, performerType: performerTypeRef };
    const existingId = await findBySlug(sdk, row.slug);
    if (existingId) {
        await sdk.updateContent(existingId, PERFORMER_CD, props, null);
        return { id: existingId, action: "updated" };
    }
    const { contentId } = await sdk.createContent(PERFORMER_CD, null);
    await sdk.updateContent(contentId, PERFORMER_CD, props, null);
    return { id: contentId, action: "created" };
}

Notes

  • Discover before writing. get_content_definition(PERFORMER_CD) confirms field names.
    Inspect a sample of existing slugs before transforming spreadsheet slugs - the convention
    may differ by customer.
  • Admin credentials come from Secrets Manager. The secret is typically
    nomad/<prefix>/adminUserCredentials. Never hardcode credentials in the script.
  • create_content and update_content are fully positional. Both require
    language_id as the last argument - pass None for the default language. Keyword
    arguments raise missing required positional argument.
  • performerType is required. Omit it and the write silently produces an incomplete
    record. Resolve the PerformerType lookup table once before the loop - it's a small,
    stable table (typically < 20 rows).
  • Idempotency is the slug. find_by_slug checks for an existing record before
    create_content, so re-running the import updates in place instead of duplicating.
    assert_unique_slugs fails-closed if the spreadsheet itself has duplicate slugs after
    transformation.
  • update_content returns None even on success. Keep the contentId from
    create_content - do not rely on the return value of update_content as confirmation.
  • Tear down what you create (Class C). Performers have no folder anchor, so they
    cannot be reclaimed by a run-root cascade. Delete with
    delete_content(sdk, content_id, PERFORMER_CD) in non-prod.

Did this page help you?