Skip to content

Collecting large tables safely

import math
import transferegovpy as tg

The cap you cannot see

The service returns at most 1000 rows per request, however many are asked for, and it does so without complaint: ask for a hundred thousand and you are given a thousand and a 206 Partial Content. Nothing in the body says the result was truncated. Only the Content-Range header does.

This package reads that header, which is why limit counts rows rather than requests, and why anything above 1000 is collected page by page:

plans = tg.get("ted", "plano_acao", limit=2500)

len(plans)
# 2500
tg.metadata(plans)["pages"]
# 3

page_size is capped at 1000 for the same reason. A larger value would be accepted by the package, silently truncated by the service, and the shortfall would look like missing data.

Ask how big it is first

tg.count("fundoafundo", "gestao_financeira_lancamentos")
# 1115444

To size everything at once, tables() takes a counts argument. It is the only part of that function that touches the network — one request per table, cached, so the second call in a session is free:

tg.tables(counts=True).sort_values("rows", ascending=False).head()
module table columns rows
fundoafundo gestao_financeira_lancamentos 32 1115444
fundoafundo gestao_financeira_subtransacoes 16 377666
transferenciasespeciais historico_pagamento_especial 5 281163
fundoafundo plano_acao_historico 5 183379
transferenciasespeciais meta_especial 16 156016

A million rows is over a thousand requests. The package throttles itself to sixty requests a minute by default, so that download takes something like twenty minutes. That is usually the wrong shape for the question being asked.

Narrow first, then collect

Two things make a large query smaller, and both happen on the server.

Select only the columns you need. gestao_financeira_lancamentos has 32 columns; if you want four of them, the other 28 need never cross the network.

tg.get(
    "fundoafundo", "gestao_financeira_lancamentos",
    select=[
        "id_lancamento_gestao_financeira",
        "id_plano_acao",
        "data_lancamento_gestao_financeira",
        "valor_lancamento_gestao_financeira",
    ],
    limit=math.inf,
)

Filter before you fetch. A year's worth of a table is usually what was wanted anyway:

tg.count(
    "fundoafundo", "gestao_financeira_lancamentos",
    data_lancamento_gestao_financeira=[tg.gte("2025-01-01"), tg.lt("2026-01-01")],
)

Why the order matters

Offset pagination asks for rows 0–999, then 1000–1999, and so on. In Postgres a query without ORDER BY has no defined row order: the planner is free to return rows differently between two executions, and if it does, page two can repeat rows from page one and skip others entirely. The result has the right number of rows and the wrong contents.

So every request this package makes carries an explicit order. By default it is the table's primary key where the API declares one, and its identifier columns otherwise:

tg.metadata(plans)["order"]
# ['id_plano_acao.asc', 'id_programa.asc', 'sq_instrumento.asc']

You can supply your own, and pagination will use it:

tg.get("ted", "plano_acao", order="vl_total_plano_acao.desc", limit=2500)

Bear in mind that ordering by a column with many ties leaves the order within each tie undefined, which brings the problem back. Prefer an identifier, or add one as a tiebreaker:

tg.get(
    "ted", "plano_acao",
    order=["vl_total_plano_acao.desc", "id_plano_acao.asc"],
    limit=2500,
)

Resuming, and taking it in pieces

offset skips rows that have already been collected, so a long download can be taken in sittings:

first = tg.get("ted", "plano_acao_etapa", limit=20000)
rest = tg.get("ted", "plano_acao_etapa", limit=math.inf, offset=20000)

Both parts must use the same order, or they are pages of two different sequences. The default order is deterministic for a given table, so leaving order alone is the safe choice here.

Long in_() lists

A filter built with in_() over a few thousand identifiers produces a URL the service cannot accept, and the failure it produces without this package is not readable: curl reports Error in the HTTP2 framing layer, which says nothing about the query. The package checks the length first:

tg.get("ted", "plano_acao", id_plano_acao=tg.in_(range(3000)))
# URLTooLongError: The request URL is 20047 bytes, over the 7000 the service
# accepts. A filter built with in_() over a long sequence is the usual cause.

Take it in batches:

import pandas as pd

def in_batches(module, table, column, values, size=300, **kwargs):
    values = list(values)
    parts = [
        tg.get(module, table, filters={column: tg.in_(values[i : i + size])},
               limit=math.inf, progress=False, **kwargs)
        for i in range(0, len(values), size)
    ]
    return pd.concat(parts, ignore_index=True)

When the count and the contents disagree

The number of rows collected is checked against the total the API reported:

IncompleteResultWarning: Collected 4820 row(s) where the API reported 4900.
The table may have changed while it was being read; check metadata() on the
result.

This is a real possibility on a long download, since the underlying tables are refreshed daily. It is a warning rather than an error because a nearly complete result is still worth having — but it must be visible, and metadata() records both numbers so the gap stays in the data rather than only in the console.

Caching

Every request is cached for an hour, so re-running an analysis does not re-fetch what it already has. Each page is cached separately, so an interrupted collection resumes from the network only where it has to.

tg.cache_dir()
# PosixPath('/tmp/transferegovpy-cache')

The default is the session's temporary directory, so nothing outlives the session unless you ask for it:

tg.cache_dir("~/.cache/transferegovpy")

Set TRANSFEREGOVPY_CACHE_DIR in your environment to make that permanent, and adjust the lifetime with tg.set_cache_ttl(86400) — the data behind these APIs is refreshed once a day, so an hour is conservative.

tg.metadata(frame)["cached"] tells you whether a result came from the network or from disk.

Rate limiting and retries

Requests are throttled to sixty a minute and retried up to four times on a 429 or a 5xx, with exponential backoff and jitter. A 400 is not retried: it means the service rejected the query itself and will reject it identically next time.

Both are adjustable, but the defaults exist to keep the package a considerate client of a public service:

tg.configure(requests_per_minute=30, max_tries=6, timeout=120)