---
title: "Upload an Image With Python Requests"
description: "Use Python requests for a context-managed multipart upload with timeouts, status-aware errors, and public URL verification."
slug: "upload-image-python-requests"
date: 2026-08-20
updated: 2026-08-20
last_tested: 2026-08-20
summary: "Open the image in binary mode, send it as the multipart file field with requests, surface the server fix, and verify the result."
cluster: Languages
intent: how-to
sources:
  - title: "Requests multipart file quickstart"
    url: "https://requests.readthedocs.io/en/latest/user/quickstart/#post-a-multipart-encoded-file"
  - title: "Requests timeout guidance"
    url: "https://requests.readthedocs.io/en/latest/user/quickstart/#timeouts"
  - title: "imgd.dev OpenAPI specification"
    url: "https://imgd.dev/openapi.json"
  - title: "imgd.dev agent reference"
    url: "https://imgd.dev/llms.txt"
---

Upload an image with Python Requests by opening the file in binary mode and passing it as the multipart `file` field. Set a timeout, accept HTTP `200` or `202`, parse the JSON, and show the server `fix` when an error occurs. Close the file and each response with context managers.

## Before you upload

imgd.dev costs **$1 per GB as a one-time storage purchase**. It has **no free tier**. Every imgd.dev image is public to anyone with its URL. Do not upload a private, confidential, or secret image.

Use Python 3.10 or later. Load `IMGD_KEY` from a secret manager and keep it outside the repository.

The example needs the Requests package. Save this line as `examples/core/python/requirements.txt`:

```text
requests>=2.32,<3
```

Create a local environment and install the package:

```bash
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -r examples/core/python/requirements.txt
test -n "$IMGD_KEY"
```

## Complete Python example

Save this code as `examples/core/python/upload_image.py`.

```python
from __future__ import annotations

import json
import os
import sys
from pathlib import Path
from typing import Final

import requests

UPLOAD_URL: Final = "https://imgd.dev/v1/upload"
METADATA_URL: Final = "https://imgd.dev/v1/images"
MAX_UPLOAD_BYTES: Final = 20 * 1024 * 1024
TIMEOUT: Final = (10, 60)

MIME_TYPES: Final = {
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".png": "image/png",
    ".gif": "image/gif",
    ".webp": "image/webp",
    ".avif": "image/avif",
}

STATUS_GUIDANCE: Final = {
    401: "Load a valid IMGD_KEY from the secret manager.",
    402: "Buy storage or delete unused images, then retry.",
    413: "Reduce the image below 20 MB, then retry.",
    415: "Use JPEG, PNG, GIF, WebP, or AVIF with the correct media type.",
}


class UploadError(RuntimeError):
    """An upload or verification error with a safe message."""


def read_json(response: requests.Response) -> object:
    try:
        return response.json()
    except ValueError as error:
        raise UploadError(
            f"The server returned invalid JSON with HTTP {response.status_code}."
        ) from error


def response_error(response: requests.Response, payload: object) -> UploadError:
    error_code = "unknown_error"
    fix = STATUS_GUIDANCE.get(
        response.status_code, "Read the response and correct the request."
    )

    if isinstance(payload, dict):
        payload_error = payload.get("error")
        payload_fix = payload.get("fix")
        if isinstance(payload_error, str):
            error_code = payload_error
        if isinstance(payload_fix, str):
            fix = payload_fix

    return UploadError(
        f"Request failed with HTTP {response.status_code} ({error_code}): {fix}"
    )


def require_upload_success(payload: object) -> dict[str, object]:
    if not isinstance(payload, dict):
        raise UploadError("The upload response is not a JSON object.")

    required_types = {
        "hash": str,
        "url": str,
        "status": str,
        "mime": str,
        "bytes": int,
    }
    for field, expected_type in required_types.items():
        if not isinstance(payload.get(field), expected_type):
            raise UploadError(f"The upload response has no valid '{field}' field.")

    return payload


def upload_image(
    session: requests.Session, image_path: Path, api_key: str
) -> dict[str, object]:
    media_type = MIME_TYPES.get(image_path.suffix.lower())
    if media_type is None:
        raise UploadError("Use a JPEG, PNG, GIF, WebP, or AVIF file name.")

    size = image_path.stat().st_size
    if size == 0:
        raise UploadError("The image file is empty.")
    if size > MAX_UPLOAD_BYTES:
        raise UploadError("The image is larger than the 20 MB upload limit.")

    headers = {"Authorization": f"Bearer {api_key}"}
    with image_path.open("rb") as image_file:
        files = {"file": (image_path.name, image_file, media_type)}
        with session.post(
            UPLOAD_URL,
            headers=headers,
            files=files,
            timeout=TIMEOUT,
        ) as response:
            payload = read_json(response)
            if response.status_code not in (200, 202):
                raise response_error(response, payload)

    return require_upload_success(payload)


def verify_upload(
    session: requests.Session, upload: dict[str, object], api_key: str
) -> None:
    image_hash = upload["hash"]
    image_url = upload["url"]
    if not isinstance(image_hash, str) or not isinstance(image_url, str):
        raise UploadError("The validated upload fields changed type.")

    headers = {"Authorization": f"Bearer {api_key}"}
    with session.get(
        f"{METADATA_URL}/{image_hash}", headers=headers, timeout=TIMEOUT
    ) as response:
        metadata = read_json(response)
        if response.status_code != 200:
            raise response_error(response, metadata)
        if not isinstance(metadata, dict) or metadata.get("hash") != image_hash:
            raise UploadError("The metadata response did not confirm the upload hash.")

    with session.get(image_url, timeout=TIMEOUT, stream=True) as response:
        if response.status_code != 200:
            raise UploadError(
                f"The public URL returned HTTP {response.status_code}."
            )
        content_type = response.headers.get("Content-Type", "")
        if not content_type.startswith("image/"):
            raise UploadError(
                f"The public URL returned the unexpected type '{content_type}'."
            )
        first_chunk = next(response.iter_content(chunk_size=1), b"")
        if not first_chunk:
            raise UploadError("The public URL returned an empty image body.")


def main() -> int:
    if len(sys.argv) != 2:
        print("Usage: python3 upload_image.py IMAGE_PATH", file=sys.stderr)
        return 64

    api_key = os.environ.get("IMGD_KEY")
    if not api_key:
        print("Set IMGD_KEY in the environment.", file=sys.stderr)
        return 78

    image_path = Path(sys.argv[1])
    try:
        with requests.Session() as session:
            upload = upload_image(session, image_path, api_key)
            verify_upload(session, upload, api_key)
    except (OSError, requests.RequestException, UploadError) as error:
        print(str(error), file=sys.stderr)
        return 1

    print(json.dumps(upload, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

## Run the example

Pass one accepted image path:

```bash
python3 examples/core/python/upload_image.py ./image.png
```

The file stays open only during the multipart request. The `with image_path.open("rb")` block closes it after the request ends.

The Session and all Response objects also use context managers. This releases network resources on success and failure.

## Expected JSON

A new PNG can return this output after the verification requests pass:

```json
{
  "hash": "0000000000000000000000000000000000000000000000000000000000000000",
  "url": "https://i.imgd.dev/i/0000000000000000000000000000000000000000000000000000000000000000",
  "status": "processing",
  "mime": "image/png",
  "bytes": 60412,
  "width": 1280,
  "height": 720,
  "alt_text": null,
  "unpublish_at": null,
  "note": "the url works immediately, serving a blurred placeholder until the moderation check finishes and it sharpens to the real image"
}
```

New bytes return HTTP `202`. Identical bytes return HTTP `200` and can add `deduplicated: true`.

The public URL returns an image immediately. It is a blurred placeholder while the status is `processing`.

## How the timeout works

`TIMEOUT = (10, 60)` sets a 10-second connection timeout and a 60-second read timeout.

Requests documents that a timeout is not a total transfer deadline. It limits how long a socket can wait without the required activity.

The program catches `requests.RequestException`. A timeout, connection error, or redirect error produces a nonzero exit code.

## How status-aware errors work

The program does not treat valid JSON as proof of success. It checks the HTTP status before it accepts the object.

For a non-`200` or non-`202` upload response, `response_error` reads `error` and `fix`. It includes the server `fix` in the final message.

A typical quota response is valid JSON:

```json
{
  "error": "quota_exceeded",
  "fix": "this upload needs more room than the storage left; buy storage or delete images, then retry",
  "topup_url": "https://imgd.dev/v1/credit",
  "suggested_gb": 1,
  "gb_price_usd": "1"
}
```

The code never prints the authorization header.

## Verify the result

`verify_upload` requests `GET /v1/images/:hash` with the key. It confirms that the metadata hash matches the upload hash.

It then requests the public URL without authentication. It checks HTTP `200`, an `image/*` content type, and at least one byte.

A later metadata request can report `live`, `review`, `blocked`, or `error`. The `review` state is terminal and stays blurred.

## Recover from API errors

Use the HTTP status and the server `fix` value.

| Status | Cause | Action |
| --- | --- | --- |
| `401` | `IMGD_KEY` is missing or invalid. | Load the correct key from the secret manager. Do not print it. |
| `402` | The account has no storage or lacks quota. | Buy storage or delete unused images. Then run the script again. |
| `413` | The upload is larger than 20 MB. | Resize or compress the image below 20 MB. |
| `415` | The media type is not accepted. | Use JPEG, PNG, GIF, WebP, or AVIF with the matching extension. |

Do not retry these errors without a change. You can retry a timeout with the same file. Content addressing returns the same hash and URL.

The local size check prevents most `413` responses. The server remains the final authority if the file changes during the request.

## Security rules

Keep `IMGD_KEY` in the environment and outside version control. Do not include it in a URL, source file, traceback, or normal output.

The script prints only the upload JSON. Its URL is public and needs no key for access.

Inspect the image before upload. Public storage does not protect private text, customer records, or account data.

## Decide if Python Requests fits

This example fits Python scripts that already use Requests. It gives the caller explicit timeouts, file cleanup, and actionable errors.

It does not fit private images, access control, unsupported files, images above 20 MB, or a free tier. Read the [service evaluation guide](/evaluate.md) first.

## Result

The script uploads one context-managed file, reports the server `fix` on failure, and verifies both metadata and the public image response.

Compare it with the [JavaScript upload guide](/blog/upload-image-javascript-nodejs/) or the [Go upload guide](/blog/upload-image-go/).

Give an agent the [integration procedure](/integrate.md). A person can [start from the home page](/#start).
