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.
Bulk-updating deals
Section titled “Bulk-updating deals”deal bulk-update selects a set of deals and applies the same change to each. Pick the
targets with exactly one selector:
pdcli deal bulk-update --ids 1,2,3 --stage 5 # explicit IDspdcli deal bulk-update --filter 9 --status won # a Pipedrive saved filter IDpdcli 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).
Scoping the set with list power-params
Section titled “Scoping the set with list power-params”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 withfilter list(pdcli filter list --type deals), then reuse the same ID for both listing anddeal 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 onid,update_time, oradd_time).--updated-since/--updated-until— bound by last-modified time. Both take RFC3339 with no fractional seconds (2026-06-01T00:00:00Z).product listsupports 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:
pdcli deal list --filter 9 --sort-by update_time --sort-direction asc # see the setpdcli deal list --filter 9 --jq '.[].id' | pdcli deal bulk-update --stage 5 # act on itWhat you can pipe on stdin
Section titled “What you can pipe on stdin”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
idfield — e.g. the output ofdeal list --output json, sopdcli deal list --status open | pdcli deal bulk-update --status wonworks.
What you can change
Section titled “What you can change”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.
Preview, then confirm
Section titled “Preview, then confirm”--dry-run lists the targets and the change without touching anything:
pdcli deal bulk-update --filter 9 --stage 5 --dry-runWould update 12 deals: 1, 2, 5, 8, 9, 13, 21, 34, 55, 89, 144, 233Change: {"stage_id":5}Without --dry-run, pdcli confirms before writing. Pass -y/--yes to skip the prompt in
scripts. Declining aborts with exit 1.
Pacing and partial failures
Section titled “Pacing and partial failures”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 permissionsError: 2 of 12 updates failedImporting 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.
| Entity | Special columns |
|---|---|
| person | name, email, phone, org_id, owner_id |
| org | name, owner_id |
For persons, email and phone become the primary email/phone. Empty cells are skipped.
name,email,Segment,owner_idJane Doe,jane@acme.com,Enterprise,7John Roe,john@acme.com,SMB,7pdcli person import people.csv --dry-run # validate every row, create nothingpdcli 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 createdA 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, LargeA 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 useError: 1 of 24 rows failedIdempotent upsert (match-or-create)
Section titled “Idempotent upsert (match-or-create)”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.
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):
| Entity | Built-in --by keys |
|---|---|
| person | email, name, phone |
| org | name |
| deal | title |
Why it refuses instead of guessing
Section titled “Why it refuses instead of guessing”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
unchangedand issues no write. - more than 1 → refuse 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).
Upserting a whole CSV
Section titled “Upserting a whole CSV”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.
pdcli person import contacts.csv --upsert --match-on email --dry-run # preview the countspdcli 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 failedWhen 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.