Skip to content

Putting the tables back together

Each module is a normalized database served one table at a time. Almost nothing useful is answerable from a single table: the money is in one, who received it in another, and what it was spent on in a third. This page maps how they fit together.

import pandas as pd
import transferegovpy as tg

The APIs do not declare their keys

The OpenAPI documents these services publish describe columns and query parameters, and nothing else — no primary keys, no foreign keys. fields() therefore cannot tell you what joins to what.

The relationships below come from the data models the government publishes alongside the APIs. The convention is regular enough to follow without them: a column named id_x in table B refers to the row of table X whose own id_x matches.

especiais

Everything hangs off the action plan, planos_acao_especiais.

programas_especiais ──< planos_acao_especiais >── beneficiarios_especiais
                              │
                              ├──< planos_trabalho_especiais
                              │        ├──< planos_trabalho_analises_especiais
                              │        ├──< planos_trabalho_historico
                              │        └──< orgaos_analises_pendentes_especiais
                              ├──< executores_especiais
                              │        ├──< meta_especiais
                              │        └──< finalidade_especiais
                              ├──< empenhos_especiais
                              │        └──< documentos_habeis_especiais
                              │                 └──< ordens_pagamentos_ordens_bancarias_especiais
                              ├──< planos_acao_historico_especiais
                              ├──< relatorios_gestao_especiais
                              └──< relatorios_gestao_novos_especiais

Note where the beneficiary lives. The action plan carries only id_beneficiario; the name, CNPJ and state are in beneficiarios_especiais. There is no way to filter action plans by state directly — you filter the beneficiaries and join:

import math

beneficiarios = tg.get("especiais", "beneficiarios_especiais", limit=math.inf)
pe = beneficiarios[beneficiarios["uf_beneficiario"] == "PE"]

planos = tg.get("especiais", "planos_acao_especiais", limit=math.inf)
planos = planos[planos["id_beneficiario"].isin(pe["id_beneficiario"])]

beneficiarios_especiais has five columns and is small enough to take whole, which makes this cheaper than it looks.

fundoafundo

Same shape, with the program at the top. Here the action plan does carry the state, so a filter does the work the join would:

planos = tg.get(
    "fundoafundo", "planos_acao",
    uf_ente_recebedor_plano_acao="PE",
    limit=math.inf,
)

parcerias

The chain here is the longest, and it is the one worth following end to end: it runs from the program that announces money to the bank statement of the account it leaves from.

programa ──< proposta ──< parceria ──< parceria_conta ──< extrato_bancario
   │            │            │
   │            │            └──< documento_habil ──< ordem_pagamento
   │            │            └──< empenho_parceria
   │            ├──< meta_proposta
   │            ├──< item_proposta
   │            ├──< cronograma_desembolso
   │            └──< analise_proposta
   └──< beneficiario_emenda_parlamentar
propostas = tg.get(
    "parcerias", "proposta",
    sg_uf_recebedor="PE", situacao_proposta="Aprovada", limit=math.inf,
)

parcerias = tg.get("parcerias", "parceria", limit=math.inf)
parcerias = parcerias[parcerias["id_proposta"].isin(propostas["id_proposta"])]

contas = tg.get("parcerias", "parceria_conta", limit=math.inf)
contas = contas[contas["id_parceria"].isin(parcerias["id_parceria"])]

extrato_bancario holds over a million rows, so join into it rather than collecting it whole — filter by the account you care about:

extratos = pd.concat(
    tg.get("parcerias", "extrato_bancario", id_parceria_conta=int(i), limit=math.inf)
    for i in contas["id_parceria_conta"]
)

Children that arrive already joined

Several child tables have no endpoint. The API folds them into the parent as an array, which means the join is already done and you only have to explode.

In parcerias: ufs_habilitadas, programa_atende_a, categorias_despesa, resultados_esperados and indicadores_programa on programa; intervenientes_proposta and categorias_despesa_proposta on proposta; etapas_proposta on meta_proposta; publicacoes_parceria on parceria; classificacoes_ingresso on parceria_conta; tipos_analise on analise_proposta; indicacoes_beneficiario on beneficiario_emenda_parlamentar; and classificacao_despesa on item_proposta.

In fundoafundo: programa_acao_orcamentaria and programa_natureza_despesa on programas, categorias_despesa_lancamento on gestao_financeira_lancamentos, and categorias_despesa_subtransacao on gestao_financeira_subtransacoes.

especiais has none: all twenty of its tables have endpoints.

To flatten one:

programas = tg.get("parcerias", "programa", limit=math.inf)

ufs = (
    programas[["id_programa", "ufs_habilitadas"]]
    .explode("ufs_habilitadas")
    .dropna(subset=["ufs_habilitadas"])
)
ufs = ufs.join(pd.json_normalize(ufs.pop("ufs_habilitadas")).set_index(ufs.index))

fields(nested=...) tells you the shape before you explode:

tg.fields("parcerias", "programa", nested="ufs_habilitadas")

Joins that do not fully resolve

Not every identifier finds its parent. Government systems have rows that predate a constraint, and rows whose parent has since been removed. Check rather than assume:

missing = ~planos["id_beneficiario"].isin(beneficiarios["id_beneficiario"])
missing.sum()

An inner join would drop those rows silently. Use a left join and count the nulls, so a gap upstream shows up as a number rather than as a quietly smaller answer.