Skip to content
pdcli
Get started

Bulk operations & CSV import

pdcli has two bulk workflows: deal bulk-update for changing many existing deals at once, and person import / org import for creating records from a CSV. Both pace their writes to stay inside Pipedrive's rate limits and report per-item failures honestly.

deal bulk-update selects a set of deals and applies the same change to each. Pick the targets with exactly one selector:

Terminal window
pdcli deal bulk-update --ids 1,2,3 --stage 5 # explicit IDs
pdcli deal bulk-update --filter 9 --status won # a Pipedrive saved filter ID
pdcli deal list --status open --jq '.[].id' | pdcli deal bulk-update --owner 42 # stdin

--ids and --filter are mutually exclusive. With neither flag and no piped input you get exit 64 (No targets — pass --ids, --filter, or pipe ids on stdin).

The cleanest way to choose what bulk work touches is to scope it with deal list (or person list, org list, product list) and pipe the result in. The list commands share a small set of selectors:

  • --filter <id> — a Pipedrive saved filter ID. Build and discover IDs with filter list (pdcli filter list --type deals), then reuse the same ID for both listing and deal bulk-update --filter.
  • --ids <a,b,c> — fetch an explicit set (max 100).
  • --sort-by / --sort-direction — order the results; the available sort fields vary by entity (e.g. deals sort on id, update_time, or add_time).
  • --updated-since / --updated-until — bound by last-modified time. Both take RFC3339 with no fractional seconds (2026-06-01T00:00:00Z). product list supports only --updated-since — products have no --updated-until.

Because a saved filter ID works identically in deal list and deal bulk-update, you can preview the exact set, then apply the change to it:

Terminal window
pdcli deal list --filter 9 --sort-by update_time --sort-direction asc # see the set
pdcli deal list --filter 9 --jq '.[].id' | pdcli deal bulk-update --stage 5 # act on it

When stdin is not a TTY, pdcli reads it and accepts three shapes:

  • Newline-separated IDs — one integer per line.
  • A JSON array of IDs[1, 2, 3].
  • A JSON array of objects with an id field — e.g. the output of deal list --output json, so pdcli deal list --status open | pdcli deal bulk-update --status won works.

Any combination of --stage, --pipeline, --status (open|won|lost), --owner, repeatable --field "Name=Value" (resolved like everywhere — see Custom fields), and a raw --body JSON merge (typed flags win). You must pass at least one change, or it's exit 64.

--dry-run lists the targets and the change without touching anything:

Terminal window
pdcli deal bulk-update --filter 9 --stage 5 --dry-run
Would update 12 deals: 1, 2, 5, 8, 9, 13, 21, 34, 55, 89, 144, 233
Change: {"stage_id":5}

Without --dry-run, pdcli confirms before writing. Pass -y/--yes to skip the prompt in scripts. Declining aborts with exit 1.

Updates run sequentially with a short gap between them, so a burst of writes stays within the 2-second rate-limit window (each write costs 10 tokens); the client's 429 backoff covers anything that slips through. Per-deal failures are collected, not thrown — you get a full summary, and any failure exits 1:

Updated 10/12 deals
✘ deal 55: Pipedrive API 404: Deal not found
✘ deal 233: Pipedrive API 403: insufficient permissions
Error: 2 of 12 updates failed

Importing persons and organizations from CSV

Section titled “Importing persons and organizations from CSV”

person import <file> and org import <file> bulk-create records from a CSV. The first row is the header; a name column is required (case-insensitive), or it's exit 64. Duplicate column headers are rejected (exit 65) rather than silently keeping only the last cell.

Each header maps to a field. Recognized special columns build standard values directly; every other header is resolved through the entity's field definitions — by human name, field_code, or hash key — including custom fields and their option labels.

EntitySpecial columns
personname, email, phone, org_id, owner_id
orgname, owner_id

For persons, email and phone become the primary email/phone. Empty cells are skipped.

name,email,Segment,owner_id
Jane Doe,jane@acme.com,Enterprise,7
John Roe,john@acme.com,SMB,7
Terminal window
pdcli person import people.csv --dry-run # validate every row, create nothing
pdcli person import people.csv # confirms, then creates (custom fields by name)

--dry-run runs the full validation — header mapping, field resolution, option-label lookups — against every row and reports the count without writing:

24 rows valid — nothing created

A bad value fails the whole run before any writes, naming the offending row (exit 65):

Error: CSV row 5: Unknown option "Huge" for field "Deal Size". Valid: Small, Medium, Large

A real import confirms (skip with -y/--yes), paces the creates like bulk-update, and reports partial failures the same way — any failure exits 1:

Imported 23/24 persons
✘ John Roe: Pipedrive API 422: email already in use
Error: 1 of 24 rows failed

person upsert, org upsert, and deal upsert make a write safe to re-run: match an existing record, then create it if absent or PATCH only the fields that changed if exactly one matches. Re-running the same command a second time is a no-op.

Terminal window
pdcli person upsert a@x.com --by email --field "Tier=Gold"
pdcli deal upsert "Acme expansion" --by title --body '{"value":5000}'
pdcli org upsert "D-42" --by "External ID" --field "Status=Active"

The match value is the positional argument; --by names the field to match on — a built-in key or a searchable custom field (address, varchar, text, double, monetary, phone):

EntityBuilt-in --by keys
personemail, name, phone
orgname
dealtitle

Pipedrive's exact_match search is not a unique-key lookup — it's case-insensitive, and a custom-field search scans every custom field at once. So pdcli re-verifies each candidate client-side and counts the survivors:

  • 0 matches → create (the match value is injected into the new record).
  • 1 match → PATCH only the differing fields; if nothing differs, it reports unchanged and issues no write.
  • more than 1refuse with exit 65, listing the colliding IDs. It never picks one.
Error: --by email="a@x.com" matches 2 person records (ids: 7, 12) — refusing to guess. Narrow the match value.

--dry-run previews the action without writing. Table output prints a one-line summary (update person #7 (2 fields)); --output json emits the full action result (action, id, changed).

person import and org import gain --upsert --match-on <field>: each row is matched on its value in the --match-on column, then created or PATCHed like the single-record upsert.

Terminal window
pdcli person import contacts.csv --upsert --match-on email --dry-run # preview the counts
pdcli person import contacts.csv --upsert --match-on email # apply

--match-on is required with --upsert and must name a column present in the CSV (else exit 64). Rows are paced like the other bulk flows; per-row failures — an ambiguous match or an empty match cell — are collected without aborting the batch and summarized by action:

18 created, 5 updated, 1 unchanged
✘ email="dup@x.com": --by email="dup@x.com" matches 2 person records (ids: 7, 12) — refusing to guess. Narrow the match value.
Error: 1 of 24 rows failed

When every failed row is a data-validation error (ambiguous match, empty match value) the command exits 65; a mix that includes a transport/API error exits 1.

pdcli v0.18.0 · MIT · not affiliated with Pipedrive