tursodatabase/turso · error · ProtocolError

invalid rowid in server response: {e}

Error message

invalid rowid in server response: {e}

What it means

When decoding a single batch statement result, the SDK reads last_insert_rowid for INSERT/REPLACE statements and coerces it to int. If the server sends a rowid that isn't an integer (wrong type or non-numeric string), _decode_batch_statement_result raises ProtocolError. It protects callers from silently returning a bogus lastrowid.

Source

Thrown at serverless/python/turso_serverless/connection.py:424

        """Decode one statement result of a batch response (section 8.4),
        or None when the statement did not complete."""
        if not isinstance(step_result, dict):
            return None
        columns = [c.get("name") or "" for c in step_result.get("cols") or []]
        rows = [tuple(decode_value(v) for v in row) for row in step_result.get("rows") or []]
        if columns:
            description = tuple((name, None, None, None, None, None, None) for name in columns)
            rowcount = -1
        else:
            description = None
            rowcount = step_result.get("affected_row_count") or 0
        lastrowid = None
        raw_rowid = step_result.get("last_insert_rowid")
        if raw_rowid is not None and _is_insert_or_replace(sql):
            try:
                lastrowid = int(raw_rowid)
            except (TypeError, ValueError) as e:
                raise ProtocolError(f"invalid rowid in server response: {e}") from None
        return BatchResult(
            rows=rows,
            description=description,
            rowcount=rowcount,
            lastrowid=lastrowid,
            rows_read=step_result.get("rows_read"),
            rows_written=step_result.get("rows_written"),
            query_duration_ms=step_result.get("query_duration_ms"),
        )

    @staticmethod
    def _raise_batch_step_error(
        step_errors: list,
        statement_count: int,
        offset: int,
        commit_index: int | None,
        results: list,
    ) -> None:

View on GitHub (pinned to c1e5928725)

Solutions

  1. Upgrade the SDK and server to aligned versions.
  2. Log the raw step_result for the failing statement (the ValueError in the message shows the bad value) and check what the server actually sent.
  3. If a proxy sits between client and server, bypass it to see whether the response changes.
  4. Only lastrowid for INSERT/REPLACE is parsed; as a workaround, RETURNING the rowid explicitly instead of relying on last_insert_rowid.

Example fix

# before
res = conn.batch([("INSERT INTO t DEFAULT VALUES", ())])[0]
rid = res.lastrowid  # ProtocolError if server sent a bad rowid
# after
res = conn.batch([("INSERT INTO t DEFAULT VALUES RETURNING rowid", ())])[0]
rid = res.rows[0][0]  # rowid comes back as a normal column value
Defensive patterns

Strategy: try-catch

Type guard

def is_valid_rowid(raw: object) -> bool:
    try:
        int(raw)
        return True
    except (TypeError, ValueError):
        return False

Try / catch

try:
    results = conn.batch(stmts)
    rid = results[0].lastrowid
except ProtocolError as e:
    if "invalid rowid" in str(e):
        log.error("server sent non-integer rowid: %s", e)
        rid = None  # fall back to RETURNING-based lookup
    else:
        raise

Prevention

When it happens

Trigger: A batch whose INSERT/REPLACE step returns last_insert_rowid as a non-numeric value (string, null-shaped object, float-like string) — e.g. protocol drift, a proxy altering JSON, or a non-conformant server.

Common situations: Older/newer server emitting rowids in a different encoding, intermediaries re-serializing numbers as strings with unexpected content, or hitting an incompatible endpoint.

Related errors


AI-assisted analysis of tursodatabase/turso@c1e5928725 (2026-08-31). Data as JSON: /api/errors/e623fd15dac8646f. Report an issue: GitHub.