2025, Nov 02 18:02

Ошибка Polars из SQLite: failed to determine supertype of i64 and binary — причины и решение

Загрузка из SQLite в Polars падает с SchemaError “failed to determine supertype of i64 and binary”? Объясняем причину и показываем фикс через явный CAST в SQL.

Загрузка большой таблицы SQLite в Polars может завершиться сбоем с загадочной на первый взгляд ошибкой SchemaError: “failed to determine supertype of i64 and binary”. Если вы тянете сотни столбцов и сообщение не указывает виновника, разбираться бывает утомительно. Однако корень проблемы прост и специфичен именно для SQLite, а починить её можно повторяемым способом на уровне SQL‑запроса, не меняя схему базы данных.

Как воспроизвести проблему

Ниже фрагмент, который пытается прочитать всю таблицу в Polars DataFrame и вызывает ошибку в окружениях, где в одном столбце смешаны числовые и двоичные значения:

import sqlite3
import polars as pl
handle = sqlite3.connect("my_database.db")
frame_in = pl.read_database(
    connection=handle,
    query="SELECT * FROM table_to_load",
    infer_schema_length=None,
)
handle.close()

Что на самом деле идёт не так

SQLite либерально относится к типам: столбец, объявленный как INTEGER, всё равно может содержать значения разных «исполняемых» видов. Polars строг и ожидает один конкретный тип данных на столбец. Когда в столбце встречаются значения, которые отображаются в несовместимые типы Polars и для них нет общего супертипа, загрузка падает. Ровно это и означает “failed to determine supertype of i64 and binary”: часть значений выглядит как 64‑битные целые, а часть — как двоичные BLOB‑ы, и к единому супертипу Polars их автоматически свести не может.

На практике это проявляется вполне приземлённо. В одном реальном случае вставка смеси Python int и numpy.int64 в столбец INTEGER привела к тому, что некоторые строки в SQLite стали BLOB. Конвертация numpy.int64 в нативный int через .item() перед вставкой устранила ошибку. В более общем виде проверить однородность столбца можно, попросив SQLite показать фактические типы значений запросом вроде SELECT count(), typeof(column) FROM table_name GROUP BY typeof(column). Если по столбцу возвращается больше одного typeof, Polars такой столбец «как есть» не примет.

Надёжнее всего: приводить типы на границе SQL

Поскольку «источником истины» здесь является SQLite, безопаснее всего приводить проблемные столбцы прямо в SQL‑запросе, чтобы Polars увидел единый тип. Два небольших приёма помогают масштабировать подход. Во‑первых, pl.read_database_uri обычно включает имя проблемного столбца в текст ошибки — это информативнее, чем работа через объект соединения. Во‑вторых, можно обернуть запрос в функцию с повтором: она разбирает ошибку, находит столбец и повторно выполняет запрос, явно добавив для него CAST. Такой путь меняет разовую «строгость» на детерминированную политику приведения типов, которую вы контролируете.

import polars as pl
def run_with_casts(sql_text: str, dsn: str, fallback_sqlite_type: str = "text") -> pl.DataFrame:
    coerced_fields: set[str] | None = None
    column_sequence: list[str] | None = None
    from_tail: str | None = None
    base_table: str | None = None
    err_re = None
    while True:
        try:
            result_df = pl.read_database_uri(sql_text, dsn)
            if coerced_fields is not None:
                import warnings
                warn_msg = f"Your query had type errors and was changed to\n{sql_text}"
                warnings.warn(warn_msg)
            return result_df
        except Exception as exc:
            import re
            if err_re is None:
                err_re = re.compile(r"(?<=name:\s)\w+")
            match = err_re.search(str(exc))
            if match is None:
                raise
            bad_col = match.group()
            if (
                coerced_fields is None
                or column_sequence is None
                or base_table is None
                or from_tail is None
            ):
                lower = sql_text.lower()
                after_select = lower.split("select", maxsplit=1)[1]
                select_list, from_tail = after_select.split("from", maxsplit=1)
                sel_cols = select_list.strip()
                base_table = from_tail.strip().replace("\n", " ").split(" ")[0]
                if sel_cols == "*":
                    import sqlite3
                    with sqlite3.connect(dsn.replace("sqlite:///", "")) as cxn:
                        cr = cxn.cursor()
                        cr.execute(f"PRAGMA table_info({base_table})")
                        meta = cr.fetchall()
                        column_sequence = [row[1] for row in meta]
                        coerced_fields = set()
                else:
                    column_sequence = [x.strip() for x in sel_cols.split(",")]
                    coerced_fields = set()
            if bad_col in coerced_fields or bad_col not in column_sequence:
                raise
            coerced_fields.add(bad_col)
            projection = ", ".join(
                [
                    f"cast({col} as {fallback_sqlite_type}) as {col}" if col in coerced_fields else col
                    for col in column_sequence
                ]
            )
            sql_text = f"select {projection} from {from_tail}"

Этот приём рассчитан на простые операторы SELECT и не занимается полноценным разбором SQL. Он практичен в случаях, когда вы выбираете либо * либо перечисление столбцов через запятую из одной таблицы.

Демонстрация от начала до конца

В следующем примере создаётся столбец со смешанными типами в SQLite, после чего применяется стратегия с повтором, чтобы принудительно привести тип и успешно загрузить данные в Polars:

import os
import sqlite3
import polars as pl
# Собираем маленькую базу с BLOB в столбце INTEGER
fn = "mydb.db"
with sqlite3.connect(fn) as cx:
    cur = cx.cursor()
    cur.execute(
        """
        CREATE TABLE IF NOT EXISTS users (
            name TEXT,
            age INTEGER
        )
        """
    )
    cur.executemany(
        "INSERT INTO users (name, age) VALUES (?, ?)",
        [("Alice", 30), ("Bob", 25), ("Charlie", b"jfjf")],
    )
dsn = f"sqlite:///{os.path.abspath(fn)}"
# Это вызовет ошибку, потому что столбец age смешивает INTEGER и BLOB
sql_stmt = "select name, age from users"
# pl.read_database_uri(sql_stmt, dsn)
# Теперь повторим с авто‑CAST к запасному типу SQLite
out = run_with_casts(sql_stmt, dsn)
print(out)

Когда чтение падает, pl.read_database_uri обычно указывает имя столбца в тексте ошибки — например, подсказывает, что сбоит age. Функция повтора затем перестраивает запрос с CAST(age AS text) и возвращает DataFrame, выводя предупреждение с показом скорректированного SQL.

Как заранее найти проблемные столбцы

Если хотите проверить набор данных до загрузки, SQLite может суммировать фактические типы по столбцам. Шаблон прост и его можно прогнать для каждого интересующего столбца:

SELECT count(), typeof(age)
FROM users
GROUP BY typeof(age);

Цель — видеть один typeof на столбец; множественные значения typeof указывают на места, где нужен явный CAST в запросе или нормализация значений до вставки. В качестве конкретной меры перевод numpy.int64 в нативный Python int через .item() перед вставкой предотвращает превращение таких значений в BLOB.

Почему это важно

Polars принципиально строг к типам столбцов — это залог производительности и корректности, но из‑за этого свободная типизация SQLite всплывает как ошибки во время загрузки данных. Понимание того, как находить и приводить смешанные столбцы, даёт детерминированные и удобные для отладки конвейеры. Типовые проблемы остаются не в аналитическом слое, а на границе между источником и движком DataFrame, где CAST дешёв и нагляден.

Выводы

Если загрузка из SQLite в Polars падает с “failed to determine supertype of i64 and binary”, скорее всего, в одном или нескольких столбцах смешаны разные фактические типы. Используйте pl.read_database_uri, чтобы получить ошибку, которая называет столбец. Приведите этот столбец в SQL или автоматизируйте повтор с приведением к стабильному запасному типу, например TEXT. По возможности предотвращайте проблему у источника — вставляйте однородные значения (например, конвертируйте numpy.int64 в int перед записью) — и проверяйте столбцы через SELECT count(), typeof(col) ... GROUP BY typeof(col). Так вы сохраните спокойствие Polars, не перестраивая базу.

Статья основана на вопросе на StackOverflow от SapereAude и ответе от Dean MacGregor.