---
title: "Python SDK"
description: "Convert spreadsheets, CSV and JSON into SQL from Python with the intosql package - upload, wait, download, webhooks and errors."
canonical: https://intosql.com/docs/sdks/python
last_updated: 2026-10-15
---

# Python SDK

`intosql` wraps the [IntoSQL API](/docs/api) for Python, one of the [official SDKs](/docs/sdks). One call uploads a file, starts the conversion and waits for the result. The package depends only on `httpx` and is fully typed.

> **Pro:** The API is part of IntoSQL Pro. Make an API key in [Settings, API keys](https://intosql.com/settings/api-keys) and keep it out of your source code.

## Install

```shell
pip install intosql
```

The package runs on Python 3.9 or newer. The client is synchronous.

## Create a client

```python
import os
from intosql import IntoSQL

intosql = IntoSQL(os.environ["INTOSQL_API_KEY"])
```

The constructor raises `ValueError` if `api_key` is an empty string.

| Option        | Type                      | Default                      | What it does                                                                                                     |
| ------------- | ------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `api_key`     | `str`                     | required                     | An API key from Settings, API keys. Sent as `Authorization: Bearer <key>`. The only positional argument.         |
| `base_url`    | `str`                     | `https://intosql.com/api/v1` | The API's base address. A trailing slash is removed.                                                             |
| `max_retries` | `int`                     | `2`                          | Attempts after the first one for rate limits, server errors and lost connections. `0` turns retries off.         |
| `timeout`     | `float`                   | `60.0`                       | Seconds each HTTP request may take, the upload included.                                                         |
| `http_client` | `httpx.Client`            | a new `httpx.Client`         | The client used for every call, the upload included. Useful for proxies, custom TLS and tests (`MockTransport`). |
| `sleep`       | `Callable[[float], None]` | `time.sleep`                 | How the client pauses between retries and while waiting. Tests pass a function that returns at once.             |

The client never sends your key to another origin. If a pagination link points somewhere other than `base_url`'s origin, the call raises instead.

The client keeps connections open. Close it with `intosql.close()`, or use it as a context manager: `with IntoSQL(key) as intosql:`. A `http_client` you pass is left open.

## Convert a file

`convert(file, name=..., spec=...)` does the whole flow and returns the finished conversion.

```python
import os
from intosql import IntoSQL, spec

intosql = IntoSQL(os.environ["INTOSQL_API_KEY"])

conversion = intosql.convert(
    "ventas.xlsx",
    name="ventas.xlsx",
    spec=spec("postgresql", table="ventas"),
)

print(conversion["report"]["rows"]["converted"], conversion["output"]["url"])
```

What happens, in order:

1. **Open.** `POST /conversions` with `name`, the file's size in bytes and `spec`. The answer has an upload address.
2. **Upload.** The file is sent with a `PUT` to that address, with the headers the API gave, as it is. A path or file object is streamed in chunks, not read into memory first.
3. **Start.** `POST /conversions/{id}/start`.
4. **Wait.** The conversion is read until its status is `done` or `failed`.

`file` is an `Upload`: `bytes`, `bytearray` or `memoryview`, a path (`str` or `pathlib.Path`), or a binary file object open for reading. The size is read from the bytes, the path or by seeking the file object; for a stream that can't seek (a socket, a pipe, a download), pass `size=` with its exact size in bytes, since the conversion only starts once the whole file has arrived.

```python
from urllib.request import urlopen

with urlopen("https://example.com/exports/pedidos.json") as response:
    conversion = intosql.convert(
        response,
        name="pedidos.json",
        spec=spec("sqlite"),
        size=int(response.headers["Content-Length"]),
    )
```

`name` helps tell the file's format, and the downloaded result is named after it: `ventas.xlsx` downloads as `ventas.sql`. Set `table` in the spec to choose the table's name.

### Database dumps

A MySQL or MariaDB dump (`.sql`, `.sql.gz`) converts to PostgreSQL like any file. Its report lists each table in `tables`, with its own columns, fixes and problems, where the dump came from in `source`, and the views, triggers, routines and events it did not carry in `not_converted`. Files that are not dumps leave these fields out.

```python
conversion = intosql.convert("shop.sql.gz", name="shop.sql.gz", spec=spec("postgresql"))

for table in conversion["report"].get("tables", []):
    print(table["name"], table["rows"])
```

### How it waits

`convert`, `convert_url` and `conversions.wait` read the conversion again and again with growing pauses: half a second, then 1, 2 and 4 seconds, then every 5 seconds. They return when the status is `done`, raise `ConversionFailedError` when it is `failed`, and `ConversionCanceledError` when it was canceled.

| Wait option | Type            | Default        | What it does                                                                                                 |
| ----------- | --------------- | -------------- | ------------------------------------------------------------------------------------------------------------ |
| `timeout`   | `float`         | `3600.0` (1 h) | Gives up after this many seconds and raises `TimeoutError` saying the conversion is still queued or running. |
| `interval`  | `float \| None` | growing pauses | Seconds between reads, instead of the growing pauses.                                                        |

Giving up only stops the waiting. The conversion keeps running on IntoSQL's servers, and you can read it later by its `id`.

```python
conversion = intosql.convert(
    "clientes_2024.xlsx",
    name="clientes_2024.xlsx",
    spec=spec("mysql"),
    timeout=10 * 60,
)
```

## Convert a file by URL

`convert_url(url, spec=...)` asks IntoSQL to download the file from a public HTTPS address, then waits the same way. There is no upload step.

```python
conversion = intosql.convert_url(
    "https://example.com/exports/ventas.csv",
    spec=spec("mysql"),
)
```

It takes `spec`, an optional `name` (otherwise the name comes from the server's `Content-Disposition` header or the address), and the wait options. The address must be HTTPS and reachable from the internet; anything else is refused with `invalid_url`. If the download fails, the conversion ends as `failed`.

## Download the result

When `status` is `done`, `output["url"]` downloads the result. The address works for ten minutes. After that, read the conversion again to get a fresh one.

```python
import httpx

fresh = intosql.conversions.retrieve(conversion["id"])

with httpx.stream("GET", fresh["output"]["url"]) as download, open("ventas.sql", "wb") as file:
    for chunk in download.iter_bytes():
        file.write(chunk)
```

Send no `Authorization` header to `output["url"]`; the address carries its own signature. Files are deleted 24 hours after the conversion finishes, or right away with `conversions.delete`.

## Build a spec

`spec(dialect, ...)` returns a full `JobSpec` with IntoSQL's defaults. Only `dialect` is required.

```python
from intosql import spec

request = spec(
    "sql_server",
    table="clientes",
    schema="ventas",
    sheet="Clientes 2024",
    batch_rows=1000,
)
```

| Option         | Type                                                                           | Default              | Spec field            |
| -------------- | ------------------------------------------------------------------------------ | -------------------- | --------------------- |
| `dialect`      | `'postgresql' \| 'mysql' \| 'mariadb' \| 'sqlite' \| 'sql_server' \| 'oracle'` | required             | `output.dialect`      |
| `format`       | `'sql' \| 'csv' \| 'json'`                                                     | `'sql'`              | `output.format`       |
| `table`        | `str \| None`                                                                  | chosen by the engine | `output.table`        |
| `schema`       | `str \| None`                                                                  | none                 | `output.schema`       |
| `sheet`        | `str \| None`                                                                  | first sheet          | `input.sheet`         |
| `create_table` | `bool`                                                                         | `True`               | `output.create_table` |
| `batch_rows`   | `int`                                                                          | `500`                | `output.batch_rows`   |

`dialect` is positional; the rest are keyword arguments. The rest is fixed by the helper: format, encoding, delimiter and header row detected from the file; column names in `snake_case`; `INSERT` statements (`style: 'inserts'`); types inferred from the first 10,000 rows; date order detected; leading zeros kept; no per-column overrides. For anything else (`bulk` output, a date order, a locale, columns renamed, kept as text or left out), change the dict it returns or write a `JobSpec` yourself. Every field is in [Conversion spec](/docs/api/spec).

```python
request = spec("postgresql", table="pedidos")

request["output"]["style"] = "bulk"
request["inference"]["date_order"] = "day_first"
request["columns"] = [{"index": 0, "as_text": True}]
```

## Conversions

`intosql.conversions` exposes each step on its own, for when you want to upload from somewhere else, start later, or not wait.

| Method                                                 | Request                                | Returns                    |
| ------------------------------------------------------ | -------------------------------------- | -------------------------- |
| `create(name=, size=, spec=)`                          | `POST /conversions`                    | `Conversion` with `upload` |
| `upload(conversion, file, size=None)`                  | `PUT` to `conversion["upload"]["url"]` | `None`                     |
| `start(id)`                                            | `POST /conversions/{id}/start`         | `Conversion`, `queued`     |
| `create_from_url(url=, spec=, name=None)`              | `POST /conversions` with `url`         | `Conversion`, `queued`     |
| `retrieve(id)`                                         | `GET /conversions/{id}`                | `Conversion`               |
| `wait(id, timeout=3600.0, interval=None)`              | `GET /conversions/{id}`, repeated      | `Conversion`, `done`       |
| `list(limit=None, status=None)`                        | `GET /conversions`, every page         | `Iterator[Conversion]`     |
| `cancel(id)`                                           | `POST /conversions/{id}/cancel`        | `Conversion`, `canceled`   |
| `delete(id)`                                           | `DELETE /conversions/{id}`             | `None`                     |
| `create_case(id, consent=True, share=, email=, note=)` | `POST /conversions/{id}/cases`         | `Case`                     |

`create` takes the file's size as `size` and sends it as the API's `bytes` field.

### Step by step

```python
import os

opened = intosql.conversions.create(
    name="ventas.xlsx",
    size=os.path.getsize("ventas.xlsx"),
    spec=spec("mariadb"),
)

intosql.conversions.upload(opened, "ventas.xlsx")
intosql.conversions.start(opened["id"])

# Later, or from a webhook handler:
conversion = intosql.conversions.retrieve(opened["id"])
```

The upload address from `create` works for an hour. `upload` raises `ValueError` if the conversion has no upload address (one read with `retrieve` never does), and raises an `IntoSQLError` with code `upload_failed` if storage doesn't answer with a 2xx.

### List

`list()` yields every conversion on the account, newest first. It fetches a page at a time (50, or `limit` from 1 to 100) and follows `links.next` until there are no more pages. `status` keeps only conversions in that status.

```python
for conversion in intosql.conversions.list(status="failed"):
    print(conversion["id"], conversion["error"]["code"], conversion["name"])
```

Pages are fetched as the loop reads them. Stop early with `break`; no further pages are fetched.

### Cancel

`cancel(id)` stops a conversion that hasn't ended and deletes its files. It stays listed as `canceled` and doesn't count toward the month. Canceling it again answers the same; canceling one that already ended raises `IntoSQLError` with code `already_finished`.

```python
intosql.conversions.cancel(conversion["id"])
```

### Send us a conversion

When a file didn't convert well, or at all, `create_case` sends the conversion to IntoSQL so support for it gets added. Pass `consent=True` only when the person agreed to send it. `share="file"` sends the file as well as the report; a failed conversion keeps its upload until it expires, so the file is still there to send.

```python
intosql.conversions.create_case(
    conversion["id"],
    consent=True,
    share="file",
    note="Exported from our ERP; the dates come out wrong.",
)
```

Cases are private, kept 90 days, and then deleted. The [privacy policy](/privacy) says how they are handled.

### Delete

`delete(id)` removes the conversion's input and output files now instead of after 24 hours. Reading it afterwards answers `not_found`.

```python
intosql.conversions.delete(conversion["id"])
```

## Usage

`usage()` returns the bytes the plan includes this month, used and left.

```python
usage = intosql.usage()

print(usage["limit"], usage["used"], usage["left"], usage["period_start"])
```

Failed conversions don't count toward `used`.

## Verify webhooks

With a webhook address saved in Settings, API keys, IntoSQL sends a signed `POST` when a conversion is done, failed or canceled. `parse_webhook` checks the `IntoSQL-Signature` header against the raw body and returns the typed event; it raises `WebhookSignatureError` when the signature doesn't match.

```python
import os

from flask import Flask, request
from intosql import WebhookSignatureError, parse_webhook

app = Flask(__name__)


@app.post("/hooks/intosql")
def intosql_webhook():
    try:
        event = parse_webhook(
            request.get_data(),
            request.headers.get("IntoSQL-Signature", ""),
            os.environ["INTOSQL_WEBHOOK_SECRET"],
        )
    except WebhookSignatureError:
        return "", 400

    if event["type"] == "conversion.done":
        # event["data"] is the conversion; event["id"] is the same on every retry.
        pass

    return "", 204
```

`verify_webhook(body, header, secret)` does the check alone and returns `True` or `False`. Both refuse a call when the header is malformed, the signature doesn't match, or the timestamp is more than 300 seconds from now. The keyword argument `tolerance` changes that window and `now` (Unix seconds) fixes the current time in tests. Pass the body exactly as received, as `bytes` or `str`, before any JSON parsing. The signature is compared in constant time. The payload and retries are in [Webhooks](/docs/api/webhooks).

## Errors

The SDK raises these exceptions.

- **`IntoSQLError`**: the API answered with an error. It has `status` (the HTTP status), `code` (stable between versions), `message` (for people), `fields` (per field messages when the code is `invalid_request`, otherwise `{}`) and `request_id`, to quote when you write to us. If the answer has no error body, `code` is `http_error`.
- **`ConversionFailedError`**: the conversion ended as `failed`. It has `id`, `code` (stable: ours, such as `timed_out`, or the engine's, such as `encrypted_file`), `problems` (what the engine found) and the explanation as its `message`.
- **`ConversionCanceledError`**: the conversion was canceled while it was awaited. It has `id`.
- **`WebhookSignatureError`**: a webhook call didn't check out. It is a `ValueError`.

A connection that keeps failing after the retries raises the `httpx` error (an `httpx.TransportError` such as `httpx.ConnectError`). Waiting past `timeout` raises `TimeoutError`.

```python
from intosql import ConversionFailedError, IntoSQLError

try:
    intosql.convert("ventas.xlsx", name="ventas.xlsx", spec=spec("oracle"))
except ConversionFailedError as error:
    print(f"Conversion {error.id} failed ({error.code}): {error.message}")
except IntoSQLError as error:
    if error.code == "over_quota":
        print("This month's conversions are used up.")
    else:
        print(error.status, error.code, error.message, error.fields)
```

Every code, its status and how to fix it is in [Errors](/docs/api/errors).

## Retries and idempotency

The client retries a request when the connection fails, when the API answers `rate_limited`, `too_many_active` or `request_in_progress`, on any 5xx, and on 408 and 425 from the network in between, up to `max_retries` times (2 by default). It waits for `Retry-After` when the answer has it, and otherwise half a second, then 1 second, doubling up to 8 seconds.

Every `POST` gets an `Idempotency-Key` header with a random UUID, and its retries reuse the same key. If a first attempt reached the API but its answer was lost, the retry gets that first answer back instead of opening a second conversion.

Not retried: the file upload to storage (a failed `PUT` raises `upload_failed`), `already_started` (the conversion is on its way; read it instead), and any other 4xx such as `invalid_spec` or `over_quota`.

## Types

Answers are the API's JSON as plain dicts, typed with `TypedDict` so editors and `mypy` check the keys. Fields and values the server adds later pass through untouched. The package exports:

| Type                      | What it is                                                                                                                                                                                |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Conversion`              | A conversion as the API answers it: `id`, `status`, `name`, `bytes`, `spec`, `report`, `error`, `output`, `upload`, `created_at`, `started_at`, `finished_at`, `expires_at`.              |
| `ConversionFailure`       | A failed conversion's `error`: `{code, message, problems}`.                                                                                                                               |
| `Case`                    | A conversion sent to IntoSQL: `id`, `conversion_id`, `share`, `status`, `created_at`, `expires_at`.                                                                                       |
| `WebhookEvent`            | What `parse_webhook` returns: `{id, type, created_at, data}`.                                                                                                                             |
| `ConversionStatus`        | `Literal['awaiting_upload', 'queued', 'running', 'done', 'failed', 'canceled']`. `Conversion["status"]` is a `str`, so a status added later doesn't break your code.                      |
| `Upload`                  | `bytes \| bytearray \| memoryview \| str \| os.PathLike[str] \| IO[bytes]`.                                                                                                               |
| `Usage`                   | `{limit, used, left, period_start}`.                                                                                                                                                      |
| `Page`                    | `{data: list[Conversion], links: {next}, meta: {per_page, next_cursor, prev_cursor}}`.                                                                                                    |
| `JobSpec` and its parts   | `InputSpec`, `OutputSpec`, `InferenceSpec`, `ColumnSpec`, `Dialect`, `OutputFormat`, `OutputStyle`, `ColumnNames`, `DateOrder`, `InputFormat`.                                            |
| `JobReport` and its parts | `ColumnReport`, `ColumnKind`, `RowCounts`, `Fix`, `FixCode`, `Problem`, `ErrorCode`, `Severity`, `DetectedInput`, `Timings`, `SourceReport`, `TableReport`, `NotConverted`, `ObjectKind`. |

The `JobSpec` and `JobReport` types follow the engine's contract, so they match what the API accepts and returns. `report` is described in [Conversions](/docs/api/conversions), and fixes in [Fixes](/docs/fixes).
