---
title: Upload a directory of images
description: Walk a local directory and upload every image to a dataset - parallel, idempotent, and directory-structure aware.
section: Guides
order: 1
---
`client.images.upload_from_directory()` walks a local directory, creates the destination
dataset if needed, and uploads everything through a thread pool. Re-runs are idempotent:
duplicate filenames are skipped, not failed.

Source: [`resources/images.py`](https://github.com/pictograph-io/pictograph-sdk/blob/v1.69.67/src/pictograph/resources/images.py)

```python
from pictograph import Client, UploadReport

client = Client()

report: UploadReport = client.images.upload_from_directory(
    dataset_name="road-signs",
    directory="./road_signs",
)
print(f"{report.images_uploaded} uploaded, {report.images_skipped} skipped")
```

## Signature

```python
client.images.upload_from_directory(
    dataset_name: str,
    directory: str | Path,
    *,
    organize_by_class: bool = True,
    preserve_structure: bool = False,
    parallel: bool = True,
    max_workers: int = 8,
    skip_existing: bool = True,
    create_if_missing: bool = True,
    progress: Callable[[int, int, str | None], None] | None = None,
) -> UploadReport
```

| Argument | Default | Purpose |
| --- | --- | --- |
| `dataset_name` | required | Destination dataset |
| `directory` | required | Local directory (walked recursively) |
| `organize_by_class` | `True` | First-level subdirectories become virtual directories |
| `preserve_structure` | `False` | Recreate the whole directory tree instead |
| `parallel` | `True` | Use a thread pool |
| `max_workers` | `8` | Pool size - higher values risk hitting the rate limit |
| `skip_existing` | `True` | Treat duplicate-filename conflicts as skips, not failures |
| `create_if_missing` | `True` | Create the dataset if it doesn't exist (else `NotFoundError`) |
| `progress` | `None` | `(completed, total, filename)` callback fired after each file |

Supported extensions: `.jpg`, `.jpeg`, `.png`, `.webp`, `.bmp`, `.tif`, `.tiff`,
`.gif`, `.heic`.

## Where each file lands

**`organize_by_class=True`** (the default) uses only the **first** subdirectory
level, which is what you want for ImageFolder-style datasets where the top directory
*is* the class. `./road_signs/stop/night/005.jpg` still lands in `/stop`.

```
./road_signs/
├── stop/         → /stop
│   ├── 001.jpg
│   └── 002.jpg
├── yield/        → /yield
│   └── 003.jpg
└── 004.jpg       → / (root)
```

**`preserve_structure=True`** recreates the tree exactly as it is on disk.

```python
report = client.images.upload_from_directory(
    dataset_name="road-signs",
    directory="./road_signs",
    preserve_structure=True,
)
```

```
./road_signs/
├── stop/
│   ├── night/
│   │   └── 005.jpg   → /stop/night
│   └── 001.jpg       → /stop
└── 004.jpg           → / (root)
```

**`organize_by_class=False`** puts every file at the root.

Image names are unique per directory, not per dataset - the same filename in two
subdirectories stays two distinct images (`/stop/001.jpg` and `/yield/001.jpg` don't
collide).

The CLI mirrors all three, and matches the web app's **Add → Directory**:

```bash
pictograph images upload-directory road-signs ./road_signs              # structure preserved
pictograph images upload-directory road-signs ./road_signs --by-class   # first level only
pictograph images upload-directory road-signs ./road_signs --flat       # everything at root
```

## What comes back

```python
@dataclass
class UploadReport:
    dataset_name: str
    images_attempted: int
    images_uploaded: int
    images_skipped: int
    failures: list[UploadFailure]  # each carries .path and .reason

    @property
    def success(self) -> bool: ...
```

Because `skip_existing` defaults to `True`, re-running the same call is safe: the second
run reports every file as skipped rather than failed. Set `skip_existing=False` to force
a re-upload, and conflicts are recorded as failures instead.

To drive a progress bar, pass a callback. It fires once per file, whether that file
succeeded or failed:

```python
def on_progress(done: int, total: int, filename: str | None) -> None:
    print(f"[{done}/{total}] {filename}")

client.images.upload_from_directory(
    dataset_name="road-signs",
    directory="./road_signs",
    progress=on_progress,
)
```

## Errors

| Raised | Cause |
| --- | --- |
| `FileNotFoundError` | `directory` doesn't exist or isn't a directory |
| `NotFoundError` | `dataset_name` missing and `create_if_missing=False` |

Per-file errors (network, validation, conflict) land in `report.failures`, not an
exception.

## See also

- [Images](/docs/api-reference/images.md) - the underlying `client.images.upload()`
- [SAM3 auto-annotation](/docs/sam3-auto-annotation.md) - label what you just uploaded