Quanto vale o metro quadrado no Recife? Sete anos de ITBI (2020–2026)
1. Introdução
1.1 O problema
Comprar um imóvel é a maior decisão financeira da maioria das famílias, e quem acompanha o mercado do Recife ouve com frequência que “o metro quadrado só sobe”. Mas o que sobe? O preço em reais correntes, ou o valor de verdade, depois de descontada a inflação? E sobe em todos os bairros e tipos de imóvel, ou apenas em alguns segmentos que passam a ser mais negociados?
Este relatório investiga essas perguntas com os registros de ITBI (Imposto sobre a Transmissão de Bens Imóveis) publicados pela Prefeitura do Recife. São dados administrativos de transações efetivamente registradas, e não preços de anúncio, e cobrem a cidade inteira por sete anos, período que inclui a pandemia e o ciclo recente de juros altos. As perguntas que guiam a análise são:
- O volume de transações mudou ao longo do período?
- O preço por m² mudou em termos reais (descontada a inflação)?
- As mudanças nas medianas refletem o mercado ou apenas uma troca no tipo de imóvel negociado (efeito composição)?
- Quais bairros mais se valorizaram, e onde está o valor mais alto?
- O que explica o preço: padrão de acabamento, idade, tamanho, andar, localização?
- O que o “valor de avaliação” registrado no ITBI realmente representa?
1.2 Dados e método em resumo
Foram usados os sete arquivos anuais (2020 a 2026) do conjunto Imposto sobre a Transmissão de Bens Imóveis (ITBI) do Portal de Dados Abertos do Recife, importados diretamente da web. Os arquivos foram empilhados e padronizados (separador, formato decimal, nomes de bairro, datas), enriquecidos com variáveis novas (preço por m², idade, andar, edifício extraído de um campo de texto livre) e deflacionados pelo IPCA, obtido do Ipeadata, para que valores de anos diferentes sejam comparáveis. A análise foi feita sobre apartamentos e casas, depois de filtros documentados.
1.3 Abordagem adotada
- Medianas em vez de médias, porque o preço por m² tem cauda longa.
- Deflação pelo IPCA para separar valorização real de inflação.
- Índice de composição fixa, que compara o preço dentro de segmentos equivalentes (padrão × faixa de área) para isolar o efeito composição.
- Ranking de bairros com mínimo de transações por janela de tempo.
- Análise dentro do mesmo prédio para medir o prêmio de andar.
- Regressão hedônica (log-linear), que estima o efeito de cada característica mantendo as demais constantes.
- Teste de sensibilidade às transações duplicadas.
1.4 Para quem isso importa
Compradores e vendedores ganham uma referência de preço em valores reais, por bairro e por segmento. Corretores e incorporadoras enxergam em que segmento o mercado de fato se movimentou. O poder público e analistas de mercado ganham um diagnóstico sobre o que a própria base do ITBI mede, o que importa para quem usa esses dados em estimativas de valor e de arrecadação.
2. Pacotes requeridos
Todos os pacotes são carregados aqui, no início. Para reproduzir a
análise, instale os que faltarem com
install.packages().
library(tidyverse) # dplyr, tidyr, ggplot2, stringr, lubridate, purrr, forcats: manipulação, datas, texto e gráficos
library(janitor) # clean_names() e remove_constant(): padroniza nomes de colunas e remove colunas sem informação
library(jsonlite) # lê a resposta JSON da API do portal para descobrir os links dos arquivos
library(stringi) # remove acentos para padronizar nomes de bairros
library(ipeadatar) # baixa o IPCA do Ipeadata, usado para deflacionar valores
library(knitr) # tabelas estáticas
library(kableExtra) # formatação das tabelas
library(DT) # tabelas interativas
library(plotly) # gráficos interativos
library(leaflet) # mapa interativo
library(scales) # formatos de eixos e rótulos (R$, %)
library(broom) # resultados do modelo em formato de tabela| Pacote | Para que foi usado |
|---|---|
tidyverse |
Importação, limpeza, transformação e gráficos em todo o relatório |
janitor |
Padronizar nomes de colunas; remover colunas constantes |
jsonlite |
Consultar a API do portal para obter os links dos arquivos |
stringi |
Remover acentos dos nomes dos bairros |
ipeadatar |
Obter a série do IPCA para a deflação (pacote fora do conteúdo da disciplina) |
knitr, kableExtra |
Tabelas formatadas |
DT |
Tabela interativa do ranking de bairros |
plotly |
Gráficos interativos (zoom, tooltip) |
leaflet |
Mapa interativo dos preços por bairro |
scales |
Formatação de valores em R$ e porcentagem |
broom |
Transformar o modelo de regressão em tabela |
# Funções auxiliares de formatação (padrão brasileiro) usadas nas tabelas e no texto
# trim = TRUE evita que format() complete com espaços à esquerda quando recebe vários valores
fmt_n <- \(x) format(round(x), big.mark = ".", decimal.mark = ",", scientific = FALSE, trim = TRUE)
fmt_brl <- \(x) paste0("R$ ", format(round(x), big.mark = ".", decimal.mark = ",", scientific = FALSE, trim = TRUE))
fmt_pct <- \(x, d = 1) paste0(ifelse(x > 0, "+", ""), format(round(100 * x, d), decimal.mark = ",", trim = TRUE), "%")
fmt_d <- \(x, d = 1) format(round(x, d), decimal.mark = ",", trim = TRUE) # decimal com vírgula
fmt_p <- \(x, d = 0) paste0(format(round(100 * x, d), decimal.mark = ",", trim = TRUE), "%") # proporção sem sinal
# percent() com padrão brasileiro (vírgula decimal): substitui scales::percent no restante do relatório
percent <- \(x, accuracy = NULL, ...) scales::percent(x, accuracy = accuracy, big.mark = ".", decimal.mark = ",", ...)
# Tabela padrão do relatório
tab <- function(x, ...) {
x <- x |> mutate(across(any_of(c("ano", "Ano", "ano_arquivo")), as.character)) # anos sem separador de milhar
kable(x, format = "html", format.args = list(big.mark = ".", decimal.mark = ","), ...) |>
kable_styling(bootstrap_options = c("striped", "condensed", "hover"), full_width = FALSE)
}3. Preparação dos dados
3.1 Fonte
Os dados vêm do Portal de Dados Abertos da Prefeitura do
Recife, conjunto Imposto sobre a Transmissão de Bens
Imóveis (ITBI): https://dados.recife.pe.gov.br/dataset/imposto-sobre-transmissao-de-bens-imoveis-itbi.
O portal publica os dados sob a licença Open Data Commons Open Database
License (ODbL). A série de inflação (IPCA, número-índice com base
dez/1993 = 100, código PRECOS12_IPCA12) vem do
Ipeadata (http://www.ipeadata.gov.br), acessado pelo pacote
ipeadatar.
3.2 O que são os dados
O conjunto reúne a relação de transações imobiliárias com recolhimento de ITBI, com as características do imóvel e informações da operação. Há um arquivo CSV por ano; este trabalho usa os de 2020 a 2026. Cada linha é uma transação, e cada arquivo tem 22 ou 23 colunas. Algumas particularidades, descobertas ao longo da importação, moldaram a limpeza:
- O separador é ponto e vírgula (
;), e não vírgula. Lido como CSV comum, o arquivo vira uma coluna só. - O formato decimal muda entre os anos. Áreas e
fração ideal usam vírgula decimal em todos os anos, mas o
valor_avaliacaousa ponto de 2020 a 2024 e vírgula em 2025 e 2026. Tratar tudo da mesma forma inflaria os valores em 100 vezes sem emitir erro. - A coluna
Distritoexiste só em 2020–2024, o que faz o esquema diferir entre os anos. - Valores ausentes aparecem como células vazias. As
coordenadas (
latitude,longitude) faltam em parte relevante das transações; o bairro pode vir como “Indefinido” (raro). - Texto não padronizado: logradouros em minúsculas,
bairros às vezes sem acento (
Varzea), e ocomplementomistura tipo de unidade, número, bloco e nome do edifício em texto livre. - O campo
ano_construcaose comporta de forma diferente ao longo do tempo (veremos na seção 4.7). - Há linhas totalmente idênticas, que podem ser registros repetidos ou unidades indistinguíveis.
| Variável | Descrição |
|---|---|
logradouro, numero,
complemento, bairro,
cod_logradouro |
Endereço (complemento em texto livre) |
valor_avaliacao |
Valor de avaliação do imóvel registrado no ITBI (não é, necessariamente, o preço de venda) |
area_terreno, area_construida |
Áreas em m² (em apartamentos, a área do terreno provavelmente se refere ao terreno do prédio) |
fracao_ideal |
Fração ideal do terreno atribuída à unidade |
ano_construcao |
Ano de construção informado |
padrao_acabamento |
Simples, Médio ou Superior |
tipo_construcao, tipo_imovel,
tipo_ocupacao, estado_conservacao |
Características do imóvel |
data_transacao, ano |
Data e ano da transação |
sfh |
Valor associado ao Sistema Financeiro da Habitação (interpretação a confirmar no dicionário do portal) |
latitude, longitude |
Coordenadas (com muitos ausentes) |
distrito |
Distrito (somente 2020–2024) |
3.3 Importação e limpeza
Passo 1: obter os arquivos
Os arquivos são baixados do portal pela API (CKAN) quando ainda não
existem na pasta dados/. Isso torna a análise reproduzível
e, ao mesmo tempo, evita baixar de novo a cada execução.
anos <- 2020:2026
arquivos <- tibble(ano_arquivo = anos,
destino = file.path("dados", paste0("itbi_", anos, ".csv")))
dir.create("dados", showWarnings = FALSE)
if (!all(file.exists(arquivos$destino))) {
# A API lista os recursos (um por ano) e seus links de download
meta <- fromJSON("https://dados.recife.pe.gov.br/api/3/action/package_show?id=imposto-sobre-transmissao-de-bens-imoveis-itbi")
recursos <- meta$result$resources |>
filter(str_detect(name, "202[0-6]")) |>
mutate(ano_arquivo = as.integer(str_extract(name, "\\d{4}")))
baixar <- arquivos |>
left_join(select(recursos, ano_arquivo, url), by = "ano_arquivo") |>
filter(!file.exists(destino))
walk2(baixar$url, baixar$destino, \(u, d) download.file(u, d, mode = "wb", quiet = TRUE))
}
stopifnot(all(file.exists(arquivos$destino)))Passo 2: ler e empilhar
Cada arquivo é lido inteiramente como texto, com o separador correto. Ler como texto evita que o R “adivinhe” tipos diferentes em anos diferentes; a conversão é feita de forma controlada no passo 4.
ler_itbi <- \(f) read_delim(f, delim = ";", col_types = cols(.default = "c"),
locale = locale(encoding = "UTF-8"))
itbi_lista <- map(arquivos$destino, ler_itbi) |> set_names(arquivos$ano_arquivo)
# Conferência: linhas e colunas por arquivo
imap_dfr(itbi_lista, \(d, n) tibble(Ano = n, Linhas = nrow(d), Colunas = ncol(d))) |> tab()| Ano | Linhas | Colunas |
|---|---|---|
| 2020 | 10.837 | 23 |
| 2021 | 13.855 | 23 |
| 2022 | 14.852 | 23 |
| 2023 | 12.670 | 23 |
| 2024 | 15.243 | 23 |
| 2025 | 16.318 | 22 |
| 2026 | 13.852 | 22 |
# Colunas que existem em alguns anos e não em todos
colunas_por_ano <- map(itbi_lista, names)
so_alguns <- setdiff(reduce(colunas_por_ano, union), reduce(colunas_por_ano, intersect))
so_alguns_txt <- paste0("`", so_alguns, "`", collapse = ", ") # para citar no texto
n_comuns <- length(reduce(colunas_por_ano, intersect)) # colunas presentes em todos os anos
n_uniao <- length(reduce(colunas_por_ano, union)) # colunas que aparecem em algum anoOs arquivos de 2025 e 2026 têm uma coluna a menos:
Distrito existe apenas nos anos anteriores. Ao empilhar, os
anos sem essa coluna recebem NA, e voltaremos a ela no
passo 5.
itbi_raw <- bind_rows(itbi_lista, .id = "ano_arquivo") |>
clean_names() |> # nomes em minúsculas e sem espaços
mutate(ano_arquivo = as.integer(ano_arquivo))A base importada tem 97.627 transações e 24
colunas. Esse número se explica assim: os arquivos originais
têm 22 colunas comuns a todos os anos, mais Distrito nos
arquivos de 2020 a 2024 (23 colunas distintas no total), e eu
acrescentei ano_arquivo, que registra de qual arquivo veio
cada linha (23 + 1 = 24). Ou seja, o conjunto original tem 22 ou 23
colunas por arquivo e 97.627 linhas no total, bem acima dos mínimos
exigidos pelo projeto (mais de 5 mil linhas e mais de 10 colunas).
Passo 3: padronizar texto
Os nomes de bairro variam em acentuação entre os anos (por exemplo,
Poço e Poco). Removê-los e padronizar a caixa
evita tratar o mesmo bairro como dois. “Indefinido” vira ausente.
Passo 4: converter tipos (a armadilha do decimal)
Veja a primeira linha de cada ano: o valor muda de ponto para vírgula decimal em 2025.
itbi_raw |>
slice_head(n = 1, by = ano_arquivo) |>
select(ano_arquivo, valor_avaliacao, area_construida, latitude) |>
tab(caption = "Formato dos números, primeira linha de cada ano")| ano_arquivo | valor_avaliacao | area_construida | latitude |
|---|---|---|---|
| 2020 | 172000.00 | 102,11 | -8.034668 |
| 2021 | 400000.00 | 118,64 | -8.034996 |
| 2022 | 938127.63 | 172,8 | -8.034174 |
| 2023 | 1068562.63 | 511,0 | -8.034273 |
| 2024 | 1068562.63 | 511,0 | -8.034273 |
| 2025 | 1068562,63 | 511 | -8.034273 |
| 2026 | 448283,29 | 118,64 | -8.034996 |
Converter com “vírgula = decimal, ponto = milhar” multiplicaria por
100 os valores de 2020 a 2024. Como nenhum campo traz separador de
milhar, a solução segura é trocar a vírgula por ponto e
converter com as.numeric(), o que funciona nos dois
formatos. Em seguida, o R confere se alguma conversão falhou.
num <- \(x) as.numeric(str_replace(x, ",", "."))
cols_num <- c("valor_avaliacao", "area_terreno", "area_construida",
"fracao_ideal", "sfh", "latitude", "longitude")
itbi <- itbi_raw |>
mutate(across(all_of(cols_num), num),
across(c(numero, ano_construcao, cod_logradouro, ano), as.integer),
data_transacao = ymd(data_transacao),
bairro = na_if(bairro_pad, "Indefinido")) |>
select(-bairro_pad, -ano_arquivo)
# Conferência: conversões que viraram NA sem estar vazias na origem (esperado: 0)
falhas <- map_int(cols_num, \(c) sum(is.na(itbi[[c]]) & !is.na(itbi_raw[[c]])))Conversões que falharam: 0. As datas batem com o ano da transação em todas as linhas? Sim.
Passo 5: o distrito (mesclar informações)
O distrito só existe de 2020 a 2024. Ele depende do
logradouro, e não do bairro: ruas de um mesmo bairro podem
pertencer a distritos diferentes. Por isso, 2025 e 2026 são preenchidos
por uma tabela logradouro → distrito, usando só os logradouros sem
ambiguidade. O restante fica ausente, sem inventar valores, e a coluna
distrito_origem registra a procedência.
n_distinct_por_logradouro <- itbi |>
filter(!is.na(distrito)) |>
summarise(n = n_distinct(distrito), .by = cod_logradouro) |>
pull(n)
lookup_distrito <- itbi |>
filter(!is.na(distrito)) |>
distinct(cod_logradouro, distrito) |>
filter(n() == 1, .by = cod_logradouro) |> # só logradouros com distrito único
rename(distrito_ref = distrito)
itbi <- itbi |>
left_join(lookup_distrito, by = "cod_logradouro") |>
mutate(distrito_origem = case_when(!is.na(distrito) ~ "original",
!is.na(distrito_ref) ~ "preenchido",
TRUE ~ "ausente"),
distrito = coalesce(distrito, distrito_ref)) |>
select(-distrito_ref)
itbi |> count(ano, distrito_origem) |>
pivot_wider(names_from = distrito_origem, values_from = n, values_fill = 0) |>
tab(caption = "Origem do distrito por ano")| ano | original | ausente | preenchido |
|---|---|---|---|
| 2020 | 10.837 | 0 | 0 |
| 2021 | 13.855 | 0 | 0 |
| 2022 | 14.852 | 0 | 0 |
| 2023 | 12.670 | 0 | 0 |
| 2024 | 15.243 | 0 | 0 |
| 2025 | 0 | 1.364 | 14.954 |
| 2026 | 0 | 1.698 | 12.154 |
Entre os logradouros com distrito conhecido, 99,2% têm um único distrito, o que sustenta usar o logradouro como chave de correspondência.
Passo 6: colunas sem informação e duplicatas
cidade e uf têm um único valor (Recife/PE)
e não carregam informação. Já as linhas totalmente
idênticas não são apagadas: são marcadas.
Podem ser registros repetidos, mas também unidades diferentes que o dado
não distingue (18% das linhas repetidas não têm complemento, o dobro da
base). A base principal exclui as repetições, e a análise de
sensibilidade (seção 4.9) mostra se isso importa.
colunas_antes <- names(itbi)
itbi <- remove_constant(itbi)
colunas_removidas <- setdiff(colunas_antes, names(itbi))
colunas_removidas_txt <- paste0("`", colunas_removidas, "`", collapse = ", ")
itbi <- itbi |>
mutate(n_copias = n(), copia_n = row_number(), .by = everything()) |>
mutate(duplicado = copia_n > 1) # TRUE a partir da 2ª cópiaColunas removidas: cidade, uf. Linhas
excedentes marcadas como duplicadas: 1.717 (1,8% da
base).
Passo 7: criar variáveis
itbi <- itbi |>
mutate(
valor_m2 = valor_avaliacao / area_construida, # preço por m²
idade_imovel = ano - ano_construcao, # negativo = construção "futura"
idade_pos = pmax(idade_imovel, 0),
construcao_futura = ano_construcao > ano,
ano_mes = floor_date(data_transacao, "month"),
usa_sfh = sfh > 0,
predio_id = paste(cod_logradouro, numero, sep = "_"), # identifica o prédio pelo endereço
grupo_imovel = case_when(
tipo_imovel %in% c("Apartamento", "Casa") ~ "Residencial",
tipo_imovel %in% c("Sala", "Loja", "Centro Comercial/Serviços") ~ "Comercial",
TRUE ~ "Outros")
)Passo 8: separar o campo complemento
O complemento traz, em texto livre, tudo junto, como
apto 1002 edf luar do rosarinho. Ele foi separado em
tipo de unidade, unidade, bloco, torre e edifício com
expressões regulares, e o andar foi estimado a partir
dos dígitos da unidade (os dois últimos são o número do apartamento).
Isso é uma aproximação, já que a numeração varia entre prédios.
itbi <- itbi |>
mutate(
tipo_unidade = str_extract(complemento, "^[a-z]+"),
unidade = str_match(complemento, "^[a-z]+\\.?\\s+(\\d+[a-z]?)\\b")[, 2],
bloco = str_match(complemento, "\\b(?:bloco|bl\\.?)\\s*([a-z0-9]+)")[, 2],
torre = str_match(complemento, "\\btorre\\s*([a-z0-9]+)")[, 2],
# nome do edifício: o que vem depois de "edf"; se não houver, o que sobra do texto
edificio_edf = str_match(complemento,
"\\bedf\\.?\\s+(.+?)(?=\\s*-|\\s+bloco|\\s+torre|$)")[, 2],
residuo = complemento |>
str_remove("^[a-z]+\\.?(\\s+\\d+[a-z]?)?\\s*") |>
str_remove_all("\\b(bloco|bl|torre)\\.?\\s*[a-z0-9]+") |>
str_squish() |>
str_remove("^conj\\.?\\s+(res\\.?\\s+)?") |>
str_replace_all("-", " ") |> str_squish(),
edificio = coalesce(edificio_edf, na_if(residuo, "")),
andar = if_else(tipo_unidade == "apto" & str_detect(unidade, "^\\d{3,4}$"),
as.integer(str_sub(unidade, 1, -3)), NA_integer_)
) |>
select(-edificio_edf, -residuo)Passo 9: filtrar valores impossíveis
O preço por m² tem lixo nas pontas: valores positivos simbólicos (menos de R$ 1 por m²) e máximos de centenas de milhares de reais por m². Em vez de cortes arbitrários, foram usadas cercas de 3×IQR na escala logarítmica, calculadas dentro de cada tipo de imóvel (só para tipos com ao menos 100 transações). Isso só remove extremos.
limites <- itbi |>
filter(valor_avaliacao > 0, area_construida > 0) |>
summarise(n = n(), q1 = quantile(log(valor_m2), .25), q3 = quantile(log(valor_m2), .75),
.by = tipo_imovel) |>
filter(n >= 100) |>
mutate(lim_inf = exp(q1 - 3 * (q3 - q1)), lim_sup = exp(q3 + 3 * (q3 - q1)))
itbi <- itbi |>
left_join(select(limites, tipo_imovel, lim_inf, lim_sup), by = "tipo_imovel") |>
mutate(valor_m2_invalido = valor_avaliacao <= 0 | area_construida <= 0,
outlier_m2 = !valor_m2_invalido & !is.na(lim_inf) &
(valor_m2 < lim_inf | valor_m2 > lim_sup))Passo 10: deflacionar pelo IPCA (mesclar com outra fonte)
Para comparar 2020 com 2026, todos os valores são convertidos para
reais do último mês com IPCA publicado. Transações em
meses ainda sem índice usam fator 1 (aproximação, assinalada em
sem_ipca). A série é guardada em
dados/ipca.rds para não depender da internet nas execuções
seguintes.
arq_ipca <- "dados/ipca.rds"
if (file.exists(arq_ipca)) {
ipca <- readRDS(arq_ipca)
} else {
ipca <- ipeadata("PRECOS12_IPCA12") |>
transmute(ano_mes = as.Date(floor_date(date, "month")), ipca_idx = value) |>
filter(ano_mes >= ymd("2020-01-01"))
saveRDS(ipca, arq_ipca)
}
mes_base <- max(ipca$ano_mes)
base_idx <- ipca$ipca_idx[ipca$ano_mes == mes_base]
mes_base_txt <- format(mes_base, "%m/%Y")
itbi <- itbi |>
left_join(ipca, by = "ano_mes") |>
mutate(sem_ipca = is.na(ipca_idx),
ipca_idx = coalesce(ipca_idx, base_idx),
fator = base_idx / ipca_idx,
valor_real = valor_avaliacao * fator,
valor_m2_real = valor_m2 * fator)Os valores reais deste relatório estão em R$ de 09/2026. Transações sem índice publicado: 100.
Passo 11: as bases de análise
# Base principal: apartamentos e casas, sem outliers de R$/m² e sem duplicatas
itbi_analise <- itbi |>
filter(!valor_m2_invalido, !outlier_m2, grupo_imovel == "Residencial", !duplicado)
# Base de sensibilidade: igual, mas com as duplicatas
itbi_analise_sens <- itbi |>
filter(!valor_m2_invalido, !outlier_m2, grupo_imovel == "Residencial")
funil <- tibble(
Etapa = c("Base importada (2020–2026)", "Valor e área válidos", "Sem outliers de R$/m²",
"Apenas apartamentos e casas", "Sem duplicatas (base principal)"),
Linhas = c(nrow(itbi), sum(!itbi$valor_m2_invalido),
sum(!itbi$valor_m2_invalido & !itbi$outlier_m2),
nrow(itbi_analise_sens), nrow(itbi_analise)))
tab(funil, caption = "Funil de filtros")| Etapa | Linhas |
|---|---|
| Base importada (2020–2026) | 97.627 |
| Valor e área válidos | 97.623 |
| Sem outliers de R$/m² | 96.394 |
| Apenas apartamentos e casas | 85.138 |
| Sem duplicatas (base principal) | 83.720 |
3.4 O conjunto de dados final
A base principal tem 83.720 transações de
apartamentos e casas, com 48 colunas. Esse número é
maior que o dos arquivos originais porque inclui as variáveis que criei:
são 21 colunas originais (as 23 dos arquivos, exceto
cidade e uf, que foram removidas por não
variarem) e 27 colunas criadas neste trabalho
(marcações de duplicata e de outlier, variáveis derivadas, o edifício e
o andar extraídos do complemento, a origem do distrito e os valores
deflacionados). A amostra abaixo mostra só as colunas centrais, em 10
linhas:
set.seed(42)
itbi_analise |>
select(Data = data_transacao, Bairro = bairro, Tipo = tipo_imovel, Padrão = padrao_acabamento,
`Área (m²)` = area_construida, `Idade` = idade_pos, Andar = andar,
`Valor avaliação (R$)` = valor_avaliacao, `R$/m² real` = valor_m2_real) |>
slice_sample(n = 10) |>
tab(digits = 0)| Data | Bairro | Tipo | Padrão | Área (m²) | Idade | Andar | Valor avaliação (R$) | R$/m² real |
|---|---|---|---|---|---|---|---|---|
| 2025-10-28 | Guabiraba | Apartamento | Superior | 60 | 0 | 2 | 220.000 | 3.845 |
| 2024-05-08 | Boa Viagem | Apartamento | Superior | 57 | 12 | 5 | 255.946 | 5.016 |
| 2026-01-14 | Campo Grande | Apartamento | Médio | 102 | 26 | 6 | 275.210 | 2.803 |
| 2024-10-17 | Passarinho | Apartamento | Superior | 71 | 1 | 12 | 200.000 | 3.061 |
| 2024-05-28 | Rosarinho | Apartamento | Superior | 201 | 15 | 14 | 1.200.000 | 6.617 |
| 2021-09-21 | Areias | Casa | Simples | 122 | 39 | – | 120.000 | 1.270 |
| 2025-07-11 | Macaxeira | Apartamento | Simples | 72 | 36 | 1 | 160.000 | 2.347 |
| 2026-08-05 | Boa Vista | Apartamento | Médio | 87 | 65 | 7 | 180.000 | 2.075 |
| 2020-10-01 | Boa Viagem | Apartamento | Médio | 100 | 16 | 6 | 364.126 | 5.168 |
| 2025-11-03 | Casa Amarela | Apartamento | Superior | 103 | 2 | 6 | 400.000 | 4.059 |
3.5 Resumo das variáveis de interesse
vars_num <- c(`Valor de avaliação (R$)` = "valor_avaliacao", `R$/m² nominal` = "valor_m2",
`R$/m² real` = "valor_m2_real", `Área construída (m²)` = "area_construida",
`Idade (anos)` = "idade_pos", `Andar (aprox.)` = "andar")
resumo_num <- itbi_analise |>
select(all_of(vars_num)) |>
pivot_longer(everything(), names_to = "Variável", values_to = "x") |>
summarise(Válidos = sum(!is.na(x)), `% ausente` = 100 * mean(is.na(x)),
Mínimo = min(x, na.rm = TRUE), Q1 = quantile(x, .25, na.rm = TRUE),
Mediana = median(x, na.rm = TRUE), Média = mean(x, na.rm = TRUE),
Q3 = quantile(x, .75, na.rm = TRUE), Máximo = max(x, na.rm = TRUE),
.by = `Variável`)
tab(resumo_num, digits = 1, caption = "Variáveis numéricas (base principal)")| Variável | Válidos | % ausente | Mínimo | Q1 | Mediana | Média | Q3 | Máximo |
|---|---|---|---|---|---|---|---|---|
| Valor de avaliação (R$) | 83.720 | 0,0 | 20.000,0 | 249.316,8 | 356.713,8 | 526.936,8 | 560.000,0 | 32.670.848,0 |
| R$/m² nominal | 83.720 | 0,0 | 330,5 | 2.860,7 | 3.667,6 | 3.935,7 | 4.611,2 | 29.462,7 |
| R$/m² real | 83.720 | 0,0 | 356,0 | 3.324,6 | 4.349,4 | 4.562,0 | 5.319,7 | 38.094,3 |
| Área construída (m²) | 83.720 | 0,0 | 5,8 | 74,7 | 98,8 | 128,0 | 153,7 | 3.869,5 |
| Idade (anos) | 83.720 | 0,0 | 0,0 | 1,0 | 12,0 | 18,2 | 30,0 | 87,0 |
| Andar (aprox.) | 74.302 | 11,2 | 0,0 | 3,0 | 7,0 | 8,7 | 13,0 | 42,0 |
vars_cat <- c(Bairro = "bairro", Distrito = "distrito", `Tipo de imóvel` = "tipo_imovel",
`Padrão de acabamento` = "padrao_acabamento", `Tipo de construção` = "tipo_construcao",
`Estado de conservação` = "estado_conservacao", `Tipo de ocupação` = "tipo_ocupacao")
resumo_cat <- itbi_analise |>
select(all_of(vars_cat)) |>
mutate(across(everything(), as.character)) |>
pivot_longer(everything(), names_to = "Variável", values_to = "valor") |>
filter(!is.na(valor)) |>
count(`Variável`, valor) |>
summarise(Categorias = n(), `Mais comum` = valor[which.max(n)],
`% da mais comum` = 100 * max(n) / sum(n), .by = `Variável`)
tab(resumo_cat, digits = 1, caption = "Variáveis categóricas (base principal)")| Variável | Categorias | Mais comum | % da mais comum |
|---|---|---|---|
| Bairro | 89 | Boa Viagem | 26,6 |
| Distrito | 6 | 6 | 36,3 |
| Estado de conservação | 3 | Bom | 98,9 |
| Padrão de acabamento | 3 | Superior | 53,2 |
| Tipo de construção | 4 | Apartamento > 4 Pavimentos | 81,8 |
| Tipo de imóvel | 2 | Apartamento | 91,6 |
| Tipo de ocupação | 3 | RESIDENCIAL | 97,6 |
Em síntese: a base cobre 89 bairros, de 02/01/2020 a 02/10/2026. 92% são apartamentos, 27% ficam em Boa Viagem e 53% têm padrão Superior. O imóvel mediano tem 99 m² e custa R$ 4.349 por m² (em R$ de 09/2026). O estado de conservação quase não varia (99% “Bom”), por isso não será usado como explicação. Por fim, 35% das transações não têm coordenadas, o que limita o mapa.
Variáveis criadas neste trabalho:
| Variável | Como foi construída |
|---|---|
valor_m2, valor_m2_real |
valor_avaliacao / area_construida; e o mesmo
deflacionado pelo IPCA |
idade_pos |
Ano da transação menos ano de construção, com mínimo 0 |
construcao_futura |
Ano de construção posterior ao da transação |
edificio, unidade, bloco,
torre, andar |
Extraídas do campo complemento |
predio_id |
Código do logradouro e número, que identifica o prédio |
distrito_origem |
Se o distrito é original, preenchido ou ausente |
duplicado, outlier_m2,
grupo_imovel |
Marcações para filtros e recortes |
4. Análise exploratória
4.1 O volume de transações
vol <- itbi_analise |> count(ano_mes)
p_vol <- ggplot(vol, aes(ano_mes, n, text = paste0(format(ano_mes, "%m/%Y"), ": ", fmt_n(n), " transações"))) +
geom_col(fill = "#2c7fb8") +
scale_x_date(date_breaks = "6 months", date_labels = "%m/%y") +
labs(title = "Transações de apartamentos e casas por mês",
x = NULL, y = "Transações") +
theme_minimal()
ggplotly(p_vol, tooltip = "text")meses_baixos <- vol |> filter(n < 0.3 * median(n)) |> mutate(rotulo = format(ano_mes, "%m/%Y"))
txt_baixos <- if (nrow(meses_baixos) > 0) paste(meses_baixos$rotulo, collapse = ", ") else "nenhum"
por_ano <- itbi_analise |> count(ano, name = "Transações")
tab(por_ano, caption = "Transações por ano (2026 vai até a data final da base)")| ano | Transações |
|---|---|
| 2020 | 9.537 |
| 2021 | 12.097 |
| 2022 | 11.657 |
| 2023 | 10.692 |
| 2024 | 13.493 |
| 2025 | 14.196 |
| 2026 | 12.048 |
O ano de menor volume é 2020 e o de maior é 2025. Meses com volume muito abaixo do típico (menos de 30% da mediana mensal): 12/2025, 10/2026. Em geral isso indica mês incompleto na coleta (o último mês da base) ou lacuna de registro, não ausência de mercado, e deve ser lido com cautela.
4.2 O m² ficou mais caro de verdade?
serie_ano <- itbi_analise |>
summarise(n = n(), nominal = median(valor_m2), real = median(valor_m2_real),
real_q1 = quantile(valor_m2_real, .25), real_q3 = quantile(valor_m2_real, .75),
.by = ano) |>
arrange(ano)
var_nom <- last(serie_ano$nominal) / first(serie_ano$nominal) - 1
var_real <- last(serie_ano$real) / first(serie_ano$real) - 1
queda_max <- min(serie_ano$real / first(serie_ano$real) - 1)
serie_long <- serie_ano |>
select(ano, nominal, real) |>
pivot_longer(-ano, names_to = "serie", values_to = "valor") |>
mutate(serie = recode(serie, nominal = "Nominal (R$ correntes)",
real = paste0("Real (R$ de ", mes_base_txt, ")")))
p_serie <- ggplot() +
geom_ribbon(data = serie_ano, aes(ano, ymin = real_q1, ymax = real_q3), fill = "#fc8d59", alpha = .15) +
geom_line(data = serie_long, aes(ano, valor, color = serie), linewidth = 1.1) +
geom_point(data = serie_long, aes(ano, valor, color = serie,
text = paste0(ano, " | ", serie, ": ", fmt_brl(valor), "/m²")), size = 2.5) +
scale_color_manual(values = c("#2c7fb8", "#d95f0e"), name = NULL) +
scale_x_continuous(breaks = 2020:2026) +
scale_y_continuous(labels = label_number(prefix = "R$ ", big.mark = ".")) +
labs(title = "Mediana do R$/m²: nominal vs. real (apartamentos e casas)",
subtitle = "Faixa laranja: intervalo interquartil do valor real",
x = NULL, y = "R$ por m²") +
theme_minimal() + theme(legend.position = "bottom")
ggplotly(p_serie, tooltip = "text") |> layout(legend = list(orientation = "h", y = -0.15))serie_ano |>
transmute(Ano = ano, Transações = n, `Nominal` = nominal, `Real` = real,
`Índice real (2020 = 100)` = 100 * real / first(real)) |>
tab(digits = 0, caption = paste0("Mediana de R$/m² (valores reais em R$ de ", mes_base_txt, ")"))| Ano | Transações | Nominal | Real | Índice real (2020 = 100) |
|---|---|---|---|---|
| 2020 | 9.537 | 3.366 | 4.797 | 100 |
| 2021 | 12.097 | 3.430 | 4.515 | 94 |
| 2022 | 11.657 | 3.470 | 4.185 | 87 |
| 2023 | 10.692 | 3.623 | 4.182 | 87 |
| 2024 | 13.493 | 3.760 | 4.159 | 87 |
| 2025 | 14.196 | 3.958 | 4.178 | 87 |
| 2026 | 12.048 | 4.532 | 4.600 | 96 |
O que isso mostra. Em valores correntes, a mediana do R$/m² variou +35% de 2020 a 2026. Descontada a inflação, a variação foi de -4,1%; no ponto mais baixo da série, o valor real estava -13% em relação a 2020. Ou seja, boa parte da alta nominal é só inflação. (2026 cobre apenas parte do ano, mas a mediana é pouco sensível a isso.)
4.3 O salto de 2026: mercado ou composição?
A mediana real de 2026 sobe em relação a 2025. Antes de chamar isso de valorização, é preciso verificar o que mudou no tipo de imóvel negociado. Aqui o foco é nos apartamentos, que são a grande maioria da base.
apt <- itbi_analise |>
filter(tipo_imovel == "Apartamento") |>
mutate(faixa_area = cut(area_construida, c(0, 50, 80, 120, 200, Inf),
labels = c("até 50 m²", "50–80 m²", "80–120 m²", "120–200 m²", "mais de 200 m²")))
comp_area <- apt |> count(ano, faixa_area) |> mutate(pct = n / sum(n), .by = ano)
p_comp <- ggplot(comp_area, aes(factor(ano), pct, fill = faixa_area,
text = paste0(ano, " | ", faixa_area, ": ", percent(pct, .1)))) +
geom_col() +
scale_y_continuous(labels = percent) +
scale_fill_brewer(palette = "Blues", name = "Área") +
labs(title = "Composição dos apartamentos negociados por faixa de área",
x = NULL, y = "Participação") +
theme_minimal()
ggplotly(p_comp, tooltip = "text")A participação de apartamentos de até 80 m² foi de 26,8% em 2020 para 41,5% em 2026. A composição mudou, e imóveis menores costumam ter R$/m² maior. Para isolar o efeito, a tabela a seguir compara segmentos equivalentes (padrão × faixa de área) entre 2024 e 2026, nas células com ao menos 30 transações em cada ano:
celulas <- apt |>
filter(ano %in% c(2024, 2026)) |>
summarise(n = n(), med = median(valor_m2_real), .by = c(ano, padrao_acabamento, faixa_area)) |>
pivot_wider(names_from = ano, values_from = c(n, med)) |>
filter(n_2024 >= 30, n_2026 >= 30) |>
mutate(var_pct = med_2026 / med_2024 - 1) |>
arrange(padrao_acabamento, faixa_area)
celulas |>
transmute(Padrão = padrao_acabamento, Área = faixa_area,
`N 2024` = n_2024, `N 2026` = n_2026,
`R$/m² 2024` = med_2024, `R$/m² 2026` = med_2026,
`Variação real` = fmt_pct(var_pct, 1)) |>
tab(digits = 0, caption = "Mediana real do R$/m² de apartamentos, por segmento")| Padrão | Área | N 2024 | N 2026 | R$/m² 2024 | R$/m² 2026 | Variação real |
|---|---|---|---|---|---|---|
| Médio | até 50 m² | 718 | 130 | 4.416 | 4.143 | -6,2% |
| Médio | 50–80 m² | 1.053 | 677 | 4.022 | 3.563 | -11,4% |
| Médio | 80–120 m² | 1.548 | 1.035 | 3.732 | 3.538 | -5,2% |
| Médio | 120–200 m² | 906 | 721 | 3.145 | 3.091 | -1,7% |
| Médio | mais de 200 m² | 202 | 182 | 2.983 | 2.933 | -1,7% |
| Simples | até 50 m² | 131 | 110 | 3.215 | 3.404 | +5,9% |
| Simples | 50–80 m² | 355 | 280 | 2.657 | 2.611 | -1,7% |
| Simples | 80–120 m² | 465 | 341 | 2.542 | 2.564 | +0,9% |
| Simples | 120–200 m² | 98 | 94 | 2.330 | 2.376 | +2,0% |
| Superior | até 50 m² | 184 | 1.192 | 6.524 | 5.665 | -13,2% |
| Superior | 50–80 m² | 1.832 | 2.291 | 3.795 | 4.835 | +27,4% |
| Superior | 80–120 m² | 1.921 | 1.943 | 4.579 | 4.768 | +4,1% |
| Superior | 120–200 m² | 1.502 | 1.195 | 4.895 | 5.007 | +2,3% |
| Superior | mais de 200 m² | 1.380 | 1.084 | 5.982 | 5.807 | -2,9% |
maior_alta <- celulas |> slice_max(var_pct, n = 1)
maior_queda <- celulas |> slice_min(var_pct, n = 1)
n_alta <- sum(celulas$var_pct > 0.02)
n_queda <- sum(celulas$var_pct < -0.02)Entre as 14 células comparáveis, o número com alta real superior a 2%
foi 4, e com queda superior a 2%, 5. A
maior alta foi em apartamentos de padrão
Superior com 50–80 m²
(+27%), e a maior queda em padrão
Superior com até 50 m²
(-13%). A alta, portanto, não é
generalizada: ela se concentra em segmentos específicos.
Atenção às colunas N 2024 e N 2026: em algumas
células o volume de transações mudou muito entre os dois anos, e
variações nessas células misturam mudança de preço com entrada de
imóveis de perfil diferente (novos lançamentos, por exemplo). Para
quantificar isso, o gráfico compara a mediana bruta com um
índice de composição fixa, que pondera as medianas das
células pelos mesmos pesos em todos os anos.
cel_ano <- apt |>
summarise(n = n(), med = median(valor_m2_real), .by = c(ano, padrao_acabamento, faixa_area)) |>
filter(n >= 20)
pesos <- apt |> count(padrao_acabamento, faixa_area, name = "n_tot") |> mutate(w = n_tot / sum(n_tot))
indice_fixo <- cel_ano |>
left_join(pesos, by = c("padrao_acabamento", "faixa_area")) |>
summarise(preco_fixo = sum(med * w) / sum(w), .by = ano)
comp <- apt |>
summarise(bruta = median(valor_m2_real), .by = ano) |>
left_join(indice_fixo, by = "ano") |>
arrange(ano) |>
mutate(`Mediana bruta` = 100 * bruta / first(bruta),
`Composição fixa` = 100 * preco_fixo / first(preco_fixo))
p_idx <- comp |>
pivot_longer(c(`Mediana bruta`, `Composição fixa`), names_to = "serie", values_to = "indice") |>
ggplot(aes(ano, indice, color = serie, text = paste0(ano, " | ", serie, ": ", round(indice, 1)))) +
geom_line(linewidth = 1.1) + geom_point(size = 2.5) +
geom_hline(yintercept = 100, linetype = "dashed", color = "grey50") +
scale_x_continuous(breaks = 2020:2026) +
scale_color_manual(values = c("#2c7fb8", "#d95f0e"), name = NULL) +
labs(title = "Apartamentos: R$/m² real, mediana bruta vs. composição fixa (2020 = 100)",
x = NULL, y = "Índice") +
theme_minimal() + theme(legend.position = "bottom")
ggplotly(p_idx, tooltip = "text") |> layout(legend = list(orientation = "h", y = -0.15))No último ano, a mediana bruta está 4,7 pontos acima do índice de composição fixa. Quando as duas linhas se afastam, a diferença é efeito de composição: a mediana bruta mistura “o preço mudou” com “o imóvel negociado mudou”.
4.4 Quais bairros mais se valorizaram?
Comparamos 2020–2021 com 2025–2026, em valores reais, só para bairros com ao menos 30 transações em cada janela (para não premiar bairros com poucas vendas).
rank_bairro <- itbi_analise |>
mutate(janela = case_when(ano %in% 2020:2021 ~ "ini", ano %in% 2025:2026 ~ "fim")) |>
filter(!is.na(janela), !is.na(bairro)) |>
summarise(n = n(), med = median(valor_m2_real), .by = c(bairro, janela)) |>
pivot_wider(names_from = janela, values_from = c(n, med)) |>
filter(n_ini >= 30, n_fim >= 30) |>
mutate(var_pct = med_fim / med_ini - 1) |>
arrange(desc(var_pct))
datatable(rank_bairro |> select(bairro, n_ini, n_fim, med_ini, med_fim, var_pct),
rownames = FALSE,
colnames = c("Bairro", "Transações 2020–21", "Transações 2025–26",
"R$/m² real 2020–21", "R$/m² real 2025–26", "Variação real"),
caption = "Valorização real do R$/m² por bairro (clique nos títulos para ordenar)",
options = list(pageLength = 10,
language = list(search = "Buscar:", lengthMenu = "Mostrar _MENU_",
info = "_START_ a _END_ de _TOTAL_",
paginate = list(previous = "Anterior", `next` = "Próximo")))) |>
formatCurrency(c("med_ini", "med_fim"), currency = "R$ ", interval = 3, mark = ".", dec.mark = ",", digits = 0) |>
formatPercentage("var_pct", digits = 1, dec.mark = ",")extremos <- bind_rows(slice_head(rank_bairro, n = 8), slice_tail(rank_bairro, n = 8)) |>
distinct(bairro, .keep_all = TRUE)
p_rank <- ggplot(extremos, aes(reorder(bairro, var_pct), var_pct, fill = var_pct > 0,
text = paste0(bairro, ": ", fmt_pct(var_pct, 1)))) +
geom_col() + coord_flip() +
scale_y_continuous(labels = percent) +
scale_fill_manual(values = c(`TRUE` = "#1a9850", `FALSE` = "#d73027"), guide = "none") +
labs(title = "Bairros com maior alta e maior queda real do R$/m²",
subtitle = "2020–21 vs. 2025–26, mínimo de 30 transações por janela", x = NULL, y = "Variação real") +
theme_minimal()
ggplotly(p_rank, tooltip = "text")top1 <- slice_head(rank_bairro, n = 1); bot1 <- slice_tail(rank_bairro, n = 1)
n_bairros_rank <- nrow(rank_bairro)
n_bairros_alta <- sum(rank_bairro$var_pct > 0)Entre os 48 bairros com amostra suficiente, 16 tiveram alta real. O maior avanço foi em Vasco Da Gama (+60%, de R$ 2.769 para R$ 4.423 por m²) e a maior queda em Ilha Do Leite (-36%). Variações grandes em bairros com poucas transações pedem cautela: o ranking é uma pista, não uma sentença.
4.5 Onde está o valor mais alto?
O mapa mostra, para cada bairro, a mediana do R$/m² real em 2025–2026 (cor) e o número de transações (tamanho). A posição de cada bairro é a mediana das coordenadas disponíveis; como 35% das transações não têm coordenada, bairros com poucos pontos podem ficar levemente deslocados.
mapa_dados <- itbi_analise |>
filter(ano %in% 2025:2026, !is.na(bairro)) |>
summarise(n = n(), med = median(valor_m2_real),
lat = median(latitude, na.rm = TRUE), lon = median(longitude, na.rm = TRUE),
.by = bairro) |>
filter(n >= 30, !is.na(lat), !is.na(lon))
pal <- colorNumeric("YlOrRd", mapa_dados$med)
leaflet(mapa_dados) |>
addTiles() |> # mapa-base OpenStreetMap (não exige chave de API)
addCircleMarkers(lng = ~lon, lat = ~lat, radius = ~pmax(4, sqrt(n) / 2.2),
color = ~pal(med), fillOpacity = .8, stroke = FALSE,
label = ~bairro,
popup = ~paste0("<b>", bairro, "</b><br>R$/m² real: ", fmt_brl(med),
"<br>Transações: ", fmt_n(n))) |>
addLegend("bottomright", pal = pal, values = ~med, title = "R$/m² real (mediana)",
labFormat = labelFormat(prefix = "R$ ", big.mark = "."))4.6 O que explica o preço?
Padrão de acabamento e idade
tab_idade <- itbi_analise |>
mutate(faixa_idade = cut(idade_pos, c(-Inf, 2, 10, 20, 40, Inf),
labels = c("0–2 anos", "3–10", "11–20", "21–40", "mais de 40"))) |>
summarise(n = n(), med = median(valor_m2_real), .by = c(padrao_acabamento, faixa_idade)) |>
filter(n >= 30)
ggplot(tab_idade, aes(faixa_idade, padrao_acabamento, fill = med)) +
geom_tile(color = "white") +
geom_text(aes(label = fmt_n(med)), size = 3.5) +
scale_fill_gradient(low = "#fff7bc", high = "#d95f0e", name = "R$/m²",
labels = label_number(big.mark = ".")) +
labs(title = "Mediana do R$/m² real por padrão de acabamento e idade do imóvel",
x = "Idade do imóvel", y = NULL) +
theme_minimal()O padrão de acabamento separa os preços de forma nítida, e a idade
não reduz o preço de maneira simples: o R$/m² real não
é mais alto nos imóveis mais novos, e sim numa faixa intermediária,
caindo depois com o tempo. Por padrão: Médio (pico em 3–10; -29% até a
faixa mais antiga); Simples (pico em 3–10; -42% até a faixa mais
antiga); Superior (pico em 3–10; -16% até a faixa mais antiga). A queda
com a idade é bem mais forte nos padrões Simples e Médio que no
Superior. Os imóveis de 0 a 2 anos ficam abaixo do pico em todos os
padrões, o que pode ter relação com o comportamento do campo
ano_construcao (seção 4.7); por isso a idade dos imóveis
mais novos deve ser lida com cautela.
Prêmio de andar dentro do mesmo prédio
Para isolar o efeito do andar da localização e do padrão, comparamos cada apartamento com a média do mesmo prédio no mesmo ano, usando prédios com ao menos 20 transações com andar conhecido.
predios_ok <- apt |>
filter(!is.na(andar), !str_detect(predio_id, "_NA$")) |>
count(predio_id, name = "n_p") |>
filter(n_p >= 20)
premio_andar <- apt |>
filter(!is.na(andar), predio_id %in% predios_ok$predio_id) |>
mutate(ly = log(valor_m2_real), desvio = ly - mean(ly), .by = c(predio_id, ano)) |>
mutate(faixa_andar = cut(andar, c(-1, 2, 5, 10, 15, 20, Inf),
labels = c("0–2", "3–5", "6–10", "11–15", "16–20", "21+"))) |>
summarise(n = n(), premio = exp(mean(desvio)) - 1, .by = faixa_andar) |>
arrange(faixa_andar)
p_andar <- ggplot(premio_andar, aes(faixa_andar, premio,
text = paste0("Andar ", faixa_andar, ": ", fmt_pct(premio, 1), " (", fmt_n(n), " transações)"))) +
geom_col(fill = "#2c7fb8") +
geom_hline(yintercept = 0) +
scale_y_continuous(labels = percent) +
labs(title = "Desvio do R$/m² em relação à média do mesmo prédio e ano, por andar",
x = "Andar (aproximado)", y = "Desvio médio") +
theme_minimal()
ggplotly(p_andar, tooltip = "text")O desvio vai de -2,3% nos andares mais baixos a +3,8% nos mais altos, uma diferença de 6,1 pontos percentuais, crescente a cada faixa de andar. Isso indica que o valor registrado no ITBI incorpora, ao menos em parte, o andar do apartamento, e que ele não é apenas uma tabela por área e localização. Como a média é calculada entre prédios e o andar é aproximado (derivado do número da unidade), a magnitude deve ser lida com cautela.
O que o “valor de avaliação” representa?
Um sinal está nos valores redondos. Preços negociados costumam ser redondos (R$ 450.000,00); valores calculados por tabela ou fórmula costumam trazer centavos. A proporção de valores múltiplos de R$ 1.000 mudou ao longo dos anos?
redondo <- itbi_analise |>
mutate(redondo = valor_avaliacao %% 1000 == 0) |>
summarise(n = n(), pct = mean(redondo), .by = ano) |>
arrange(ano)
p_red <- ggplot(redondo, aes(ano, pct, text = paste0(ano, ": ", percent(pct, .1), " de valores redondos"))) +
geom_line(color = "#2c7fb8", linewidth = 1.1) + geom_point(color = "#2c7fb8", size = 2.5) +
scale_x_continuous(breaks = 2020:2026) +
scale_y_continuous(labels = percent, limits = c(0, 1)) +
labs(title = "Proporção de valores de avaliação múltiplos de R$ 1.000", x = NULL, y = "Participação") +
theme_minimal()
ggplotly(p_red, tooltip = "text")mesmo_valor <- itbi_analise |>
filter(!is.na(complemento)) |>
mutate(imovel_id = paste(predio_id, complemento)) |>
filter(n_distinct(ano) > 1, .by = imovel_id) |>
summarise(n_valores = n_distinct(valor_avaliacao), .by = imovel_id) |>
summarise(imoveis = n(), prop = mean(n_valores == 1))A proporção de valores redondos foi de 45% em 2020
para 64% em 2026. Além disso, entre os 6.997
imóveis que aparecem em anos diferentes, apenas
17% mantêm exatamente o mesmo valor: não se
trata de uma tabela fixa. Uma hipótese compatível com esses
números, que precisa ser confirmada na legislação
municipal, é que a base de cálculo seja o maior entre o valor
declarado na venda e um valor de referência da prefeitura. Nesse caso, a
fatia de valores redondos (declarados) crescendo sugere que, com o
tempo, o preço declarado passou a superar a referência com mais
frequência. Seja como for, o ponto metodológico é firme: o
valor_avaliacao é a base do ITBI e não um preço de mercado
puro.
4.7 O campo ano_construcao mudou de comportamento
cons <- itbi |>
summarise(`Construção futura` = mean(ano_construcao > ano),
`Construção no ano da venda` = mean(ano_construcao == ano), .by = ano) |>
arrange(ano) |>
mutate(`Soma` = `Construção futura` + `Construção no ano da venda`)
fut_ultimo <- last(cons$`Construção futura`) # para citar no texto
cons |>
mutate(across(-ano, \(x) percent(x, .1))) |>
rename(Ano = ano) |>
tab(caption = "Participação de transações com ano de construção igual ou posterior ao da venda")| Ano | Construção futura | Construção no ano da venda | Soma |
|---|---|---|---|
| 2020 | 5,4% | 4,6% | 10,0% |
| 2021 | 3,9% | 5,2% | 9,1% |
| 2022 | 3,8% | 10,8% | 14,6% |
| 2023 | 3,8% | 7,7% | 11,4% |
| 2024 | 0,3% | 17,3% | 17,6% |
| 2025 | 0,0% | 19,0% | 19,0% |
| 2026 | 0,0% | 14,8% | 14,8% |
Nos primeiros anos, parte das transações traz ano de construção
posterior à venda (imóveis possivelmente na planta); a
partir de 2024 isso praticamente desaparece (0% em 2026), enquanto
cresce a participação de imóveis com ano de construção
igual ao da venda. Isso é compatível com uma mudança na
forma de registrar o campo, embora não prove. A consequência prática é
que não é seguro comparar “na planta” e “pronto” entre
anos com esse campo, e a idade dos imóveis mais novos deve ser
lida com cautela. Por isso a variável construcao_futura não
foi usada como achado.
4.8 Regressão: o que explica o R$/m² mantendo as demais coisas constantes?
Para estimar o efeito de cada característica mantendo as demais constantes, ajustamos uma regressão do logaritmo do R$/m² real para apartamentos. No modelo log-linear, o efeito de uma variável binária ou do andar é (exp(coeficiente) − 1) × 100, em porcentagem; para as variáveis em log, o coeficiente é uma elasticidade.
dados_mod <- apt |>
filter(!is.na(andar), !is.na(bairro)) |>
mutate(bairro_grp = fct_lump_n(bairro, 15, other_level = "Outros bairros") |> fct_relevel("Outros bairros"),
padrao = fct_relevel(padrao_acabamento, "Médio"),
ano_f = factor(ano),
log_area = log(area_construida),
log_idade = log1p(idade_pos),
usa_sfh = factor(usa_sfh, c(FALSE, TRUE), c("não", "sim")))
modelo <- lm(log(valor_m2_real) ~ padrao + log_area + log_idade + andar + usa_sfh + ano_f + bairro_grp,
data = dados_mod)
efeitos <- tidy(modelo, conf.int = TRUE) |>
filter(term != "(Intercept)") |>
mutate(efeito = exp(estimate) - 1, ic_inf = exp(conf.low) - 1, ic_sup = exp(conf.high) - 1)
r2 <- glance(modelo)$r.squaredO modelo usa 74.295 apartamentos e explica 38% da variação do log do R$/m² real.
efeitos |>
filter(!str_starts(term, "ano_f"), !str_starts(term, "bairro_grp")) |>
transmute(Termo = case_when(
term == "padraoSimples" ~ "Padrão Simples (vs. Médio)",
term == "padraoSuperior" ~ "Padrão Superior (vs. Médio)",
term == "log_area" ~ "Área (em log)",
term == "log_idade" ~ "Idade (em log)",
term == "andar" ~ "Cada andar adicional",
term == "usa_sfhsim" ~ "Usa SFH (vs. não)",
TRUE ~ term),
Coeficiente = round(estimate, 3),
`Efeito no R$/m²` = if_else(term %in% c("log_area", "log_idade"),
"elasticidade", percent(efeito, .1)),
`IC 95%` = if_else(term %in% c("log_area", "log_idade"),
paste0(format(round(conf.low, 3), decimal.mark = ","), " a ", format(round(conf.high, 3), decimal.mark = ",")),
paste0(percent(ic_inf, .1), " a ", percent(ic_sup, .1)))) |>
tab(caption = "Efeitos estimados sobre o R$/m² real (apartamentos)")| Termo | Coeficiente | Efeito no R$/m² | IC 95% |
|---|---|---|---|
| Padrão Simples (vs. Médio) | -0,131 | -12,3% | -13,1% a -11,5% |
| Padrão Superior (vs. Médio) | 0,193 | 21,3% | 20,7% a 21,9% |
| Área (em log) | 0,035 | elasticidade | 0,030 a 0,039 |
| Idade (em log) | -0,059 | elasticidade | -0,061 a -0,057 |
| Cada andar adicional | 0,009 | 0,9% | 0,9% a 1,0% |
| Usa SFH (vs. não) | 0,007 | 0,7% | 0,3% a 1,2% |
Dobrar a área do apartamento está associado a uma variação de 2,4% no R$/m², com as demais características constantes. Os efeitos de padrão e de andar aparecem na tabela. Já os efeitos de ano e de bairro vêm a seguir.
efeitos |>
filter(str_starts(term, "ano_f")) |>
transmute(Ano = str_remove(term, "ano_f"), `Efeito vs. 2020` = percent(efeito, .1)) |>
tab(caption = "Efeito de cada ano sobre o R$/m² real, controlando as características")| Ano | Efeito vs. 2020 |
|---|---|
| 2021 | 0,9% |
| 2022 | -6,4% |
| 2023 | -4,4% |
| 2024 | -6,6% |
| 2025 | -6,9% |
| 2026 | -1,8% |
efeitos |>
filter(str_starts(term, "bairro_grp")) |>
arrange(desc(efeito)) |>
transmute(Bairro = str_remove(term, "bairro_grp"), `Efeito vs. outros bairros` = percent(efeito, .1)) |>
slice_head(n = 8) |>
tab(caption = "Maiores prêmios de bairro (entre os 15 mais frequentes)")| Bairro | Efeito vs. outros bairros |
|---|---|
| Pina | 35,9% |
| Boa Viagem | 18,9% |
| Tamarineira | 11,7% |
| Santo Amaro | 11,2% |
| Boa Vista | 10,9% |
| Madalena | 10,7% |
| Torre | 7,4% |
| Encruzilhada | 7,2% |
O efeito de ano é a valorização real “limpa”: o que sobra depois de controlar padrão, tamanho, idade, andar, financiamento e bairro. Quando ele difere da variação da mediana bruta (seção 4.2), a diferença é, mais uma vez, composição.
4.9 Teste de sensibilidade: as duplicatas importam?
sens <- bind_rows(
itbi_analise |> summarise(mediana = median(valor_m2_real), .by = ano) |> mutate(base = "Sem duplicatas"),
itbi_analise_sens |> summarise(mediana = median(valor_m2_real), .by = ano) |> mutate(base = "Com duplicatas")) |>
pivot_wider(names_from = base, values_from = mediana) |>
arrange(ano) |>
mutate(`Diferença` = `Com duplicatas` / `Sem duplicatas` - 1)
sens |>
mutate(Diferença = percent(Diferença, .01)) |>
rename(Ano = ano) |>
tab(digits = 0, caption = "Mediana real do R$/m² com e sem as transações duplicadas")| Ano | Sem duplicatas | Com duplicatas | Diferença |
|---|---|---|---|
| 2020 | 4.797 | 4.801 | 0,08% |
| 2021 | 4.515 | 4.508 | -0,15% |
| 2022 | 4.185 | 4.180 | -0,13% |
| 2023 | 4.182 | 4.182 | -0,01% |
| 2024 | 4.159 | 4.155 | -0,07% |
| 2025 | 4.178 | 4.178 | 0,00% |
| 2026 | 4.600 | 4.592 | -0,17% |
A maior diferença entre as duas versões é de 0,17%. Como a diferença é pequena, as conclusões não dependem das duplicatas.
5. Conclusões
5.1 O problema
Perguntamos se o m² do Recife ficou, de fato, mais caro entre 2020 e 2026, onde e por quê, e o que os registros de ITBI realmente medem.
5.2 Como abordamos
Importamos sete arquivos anuais do Portal de Dados Abertos (97.627 transações), padronizamos separadores, decimais e nomes, separamos o campo de complemento, mesclamos com a série do IPCA e filtramos extremos de forma documentada, chegando a 83.720 transações de apartamentos e casas. A análise combinou medianas deflacionadas, um índice de composição fixa, ranking de bairros, comparação dentro do mesmo prédio e uma regressão hedônica.
5.3 O que descobrimos
- A alta nominal esconde a inflação. A mediana do R$/m² variou +35% em valores correntes, mas -4,1% em termos reais, e chegou a -13% abaixo de 2020 no ponto mais baixo.
- A alta recente é concentrada. Das 14 combinações comparáveis de padrão e tamanho, 4 subiram mais de 2% em termos reais entre 2024 e 2026 e 5 caíram mais de 2%. A maior alta foi em apartamentos Superior de 50–80 m² (+27%).
- O mix mudou. A participação de apartamentos de até 80 m² foi de 27% para 42%, e isso empurra a mediana bruta para cima independentemente de qualquer valorização.
- Bairros divergem. O maior avanço real foi em Vasco Da Gama (+60%) e a maior queda em Ilha Do Leite (-36%), entre 48 bairros com amostra suficiente.
- O valor registrado não é um preço puro. Apenas 17% dos imóveis que reaparecem em anos diferentes mantêm o mesmo valor, e a fatia de valores redondos foi de NA para NA.
5.4 Implicações
Quem usa o R$/m² do ITBI para decidir deve comparar em valores reais e em segmentos equivalentes, e não ler a mediana geral como “o preço do mercado”. Para compradores e vendedores, a leitura por bairro e por padrão é mais útil que o número da cidade. Para o poder público e analistas, o ITBI é uma base valiosa, mas o campo de valor tem um componente de referência cuja natureza convém esclarecer antes de usá-lo como proxy de preço.
5.5 Limitações e próximos passos
- O
valor_avaliacaonão é, necessariamente, o preço de venda; a hipótese sobre sua composição precisa ser confirmada com a legislação e o dicionário do portal. - A base de 2026 é parcial, e há meses com volume atípico (12/2025, 10/2026).
- Parte das coordenadas está ausente (NA), e o distrito de 2025–2026 é imputado por logradouro.
- O campo
ano_construcaomuda de comportamento no tempo, o que impede comparar imóveis “na planta” e “prontos” entre anos; o andar é uma aproximação. - As duplicatas foram marcadas, não apagadas, e a análise de sensibilidade mostra o efeito; mesmo assim, não é possível saber, com estes dados, quais são registros repetidos de fato.
Para ir além: construir um índice de vendas
repetidas (o mesmo imóvel vendido duas vezes), cruzar com renda
e IDH por bairro, geocodificar os endereços sem coordenada, comparar com
índices de preços de anúncio e publicar um painel interativo
(shiny).
Apêndice: ambiente
## R version 4.6.1 (2026-06-24 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26300)
##
## Matrix products: default
## LAPACK version 3.12.1
##
## locale:
## [1] LC_COLLATE=Portuguese_Brazil.utf8 LC_CTYPE=Portuguese_Brazil.utf8
## [3] LC_MONETARY=Portuguese_Brazil.utf8 LC_NUMERIC=C
## [5] LC_TIME=Portuguese_Brazil.utf8
##
## time zone: America/Sao_Paulo
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] broom_1.0.13 scales_1.4.0 leaflet_2.2.3 plotly_4.12.1
## [5] DT_0.34.0 kableExtra_1.4.1 knitr_1.52 ipeadatar_0.2.1
## [9] stringi_1.8.9 jsonlite_2.0.0 janitor_2.2.1 lubridate_1.9.5
## [13] forcats_1.0.1 stringr_1.6.0 dplyr_1.2.1 purrr_1.2.2
## [17] readr_2.2.0 tidyr_1.3.2 tibble_3.3.1 ggplot2_4.0.3
## [21] tidyverse_2.0.0
##
## loaded via a namespace (and not attached):
## [1] gtable_0.3.6 xfun_0.61 bslib_0.12.0
## [4] htmlwidgets_1.6.4 tzdb_0.5.0 vctrs_0.7.3
## [7] tools_4.6.1 crosstalk_1.2.2 generics_0.1.4
## [10] stats4_4.6.1 parallel_4.6.1 pkgconfig_2.0.3
## [13] data.table_1.18.6.1 RColorBrewer_1.1-3 S7_0.2.2
## [16] lifecycle_1.0.5 compiler_4.6.1 farver_2.1.2
## [19] textshaping_1.0.5 snakecase_0.11.1 htmltools_0.5.9
## [22] sass_0.4.10 yaml_2.3.12 pillar_1.11.1
## [25] crayon_1.5.3 jquerylib_0.1.4 cachem_1.1.0
## [28] tidyselect_1.2.1 digest_0.6.39 bookdown_0.48
## [31] labeling_0.4.3 rmdformats_1.0.4 fastmap_1.2.0
## [34] grid_4.6.1 cli_3.6.6 magrittr_2.0.5
## [37] withr_3.0.3 backports_1.5.1 bit64_4.8.6
## [40] timechange_0.4.0 rmarkdown_2.32 httr_1.4.9
## [43] bit_4.6.0 otel_0.2.0 hms_1.1.4
## [46] evaluate_1.0.5 viridisLite_0.4.3 rlang_1.3.0
## [49] glue_1.8.1 xml2_1.6.0 svglite_2.2.2
## [52] rstudioapi_0.19.0 vroom_1.7.1 R6_2.6.1
## [55] systemfonts_1.3.2