---
title: Batch
description: Bulk move, copy, delete, and update across many images in one round-trip.
section: API Reference
order: 10
---
Bulk image operations on a single dataset. Each call accepts a list of image IDs and returns a `BatchResult` with per-item failure context — partial success does not raise.

```python
from pictograph import Client
client = Client()
```

## move

Move images to a different virtual folder within the same dataset.

```python
result = client.batch.move(
    dataset_name="my-dataset",
    image_ids=["img-1", "img-2", "img-3"],
    target_folder_path="/sorted/cars",
)
print(result.succeeded, result.failed_count, result.failures)
```

Storage paths are immutable — "move" updates `virtual_folder_path`; the underlying image bytes don't move.

## copy

Copy images to a different folder. Server-side copy of the underlying bytes (instant, zero data transfer).

```python
result = client.batch.copy(
    dataset_name="my-dataset",
    image_ids=["img-1", "img-2"],
    target_folder_path="/cars-copy",
    duplicate_handling="rename",   # collision policy in the destination
    copy_annotations=False,        # destination images start without annotations
)
```

| Arg | Type | Default | Notes |
| --- | --- | --- | --- |
| `dataset_name` | `str` | required | |
| `image_ids` | `Sequence[str]` | required | |
| `target_folder_path` | `str` | `"/"` | Destination virtual folder |
| `duplicate_handling` | `Literal["rename", "skip", "overwrite"]` | `"rename"` | How to handle filename collisions |
| `copy_annotations` | `bool` | `False` | When `True`, copy `annotations_json` and `status` too |

## delete

Soft-archive by default; permanent on request.

```python
result = client.batch.delete(
    dataset_name="my-dataset",
    image_ids=["img-1", "img-2", "img-3"],
    permanent=False,                 # archive (recoverable)
)
```

`permanent=True` purges the stored bytes — irreversible. Requires `admin`+ role.

## update

Update metadata fields on a batch of images. Pass exactly the fields you want to change — `None` is omitted from the request.

```python
result = client.batch.update(
    dataset_name="my-dataset",
    image_ids=["img-1", "img-2"],
    status="complete",
    is_archived=False,
)
```

| Arg | Type | Default | Notes |
| --- | --- | --- | --- |
| `dataset_name` | `str` | required | |
| `image_ids` | `Sequence[str]` | required | |
| `status` | `str \| None` | `None` | `"new"`, `"annotate"`, `"review"`, `"complete"` |
| `display_name` | `str \| None` | `None` | Display override |
| `is_archived` | `bool \| None` | `None` | `True` archives; `False` restores |

`ValidationError` if every field is `None` (the update would be a no-op).

## BatchResult

| Attribute | Type | Notes |
| --- | --- | --- |
| `succeeded` | `list[str]` | IDs the op completed for |
| `failed_count` | `int` | `len(failures)` |
| `failures` | `list[BatchFailure]` | `{image_id, reason}` per failure |
| `success` | `bool` (property) | `failed_count == 0` |

## Errors

| Status | Exception | Cause |
| --- | --- | --- |
| 403 | `ForbiddenError` | `permanent=True` requires `admin`+ role |
| 404 | `NotFoundError` | Dataset missing, or every `image_id` invalid |
| 422 | `ValidationError` | Invalid field value or empty update |

## Why batch over loops

Reorganizing 10K images is one round-trip with `batch.move()` versus 10K with `images.update()`. Bulk operations are implemented server-side as single statements, not loops.