Importe CSV no sqlite3 e Agregue os Dados: O Próximo Passo Depois de sort | uniq -c

Importe CSV no sqlite3 e Agregue os Dados: O Próximo Passo Depois de sort | uniq -c

O que você vai conseguir fazer

  • Você consegue carregar um arquivo CSV em uma tabela do sqlite3 com `.import --csv`
  • Você consegue contar em dois ou mais eixos e juntar tabelas com `GROUP BY` e `JOIN`
  • Você consegue decidir se o trabalho pede `sort | uniq -c` ou SQL

Pré-requisitos (leia estes primeiro)

O Que Você Vai Aprender

  • Como decidir quais contagens o sort | uniq -c resolve e quais pedem SQL
  • Como carregar em uma linha um CSV feito com awk em uma tabela do sqlite3
  • Como usar GROUP BY / JOIN para contagens em dois eixos, junções com outra tabela e proporções, e depois devolver o resultado para um pipe

Conclusão (o padrão prático)

  • Contar uma coluna → cut | sort | uniq -c | sort -rn basta
  • Dois ou mais eixos, junções ou proporções → transforme os dados em CSV, carregue no sqlite3 e agregue com SQL
  • Carregar → sqlite3 logs.db ".import --csv access.csv access"
  • De volta ao pipe → sqlite3 -csv logs.db "SELECT ..." | tr -d '\r' | sort ...

Pré-requisitos (ambiente)

  • Você precisa do comando sqlite3 (a CLI do SQLite). No Debian / Ubuntu, instale com sudo apt install sqlite3; em sistemas da família RHEL, use sudo dnf install sqlite
  • As opções --csv / --skip do .import foram adicionadas no SQLite 3.32.0 (SQLite Release History). Confira a sua versão com sqlite3 --version
  • Os comandos e as saídas deste artigo foram executados e conferidos com o sqlite3 3.45.1. O comportamento dos comandos de ponto segue a Command Line Shell For SQLite (série 3.54, obtida em 2026-10-24)
  • A partir da 3.53.0, a exibição padrão ao rodar a CLI em um terminal passou a ser uma tabela desenhada com caracteres de borda (item 3.53.0 do Release History). Para evitar diferenças de exibição entre versões, todo comando SQL que mostra resultados neste artigo usa -csv

Primeiro, alguns termos.

  • Tabela: um recipiente de dados formado por linhas e colunas. Um arquivo CSV corresponde a uma tabela
  • Coluna: um item vertical da tabela, como ip ou status. É o mesmo que um "campo" no CSV
  • SQL: uma linguagem de consulta para selecionar, agrupar e contar as linhas de tabelas. As instruções que começam com SELECT ... são SQL
  • Comando de ponto: um comando próprio do sqlite3 que começa com ., como .import. Não é SQL
  • Agregação: agrupar linhas e calcular contagens, totais ou proporções

Como escolher entre sort | uniq -c e sqlite3?

Conclusão: Para listar contagens na vertical, o pipe basta. Para pôr vários valores lado a lado, cruzar com outra tabela ou calcular proporções, carregue os dados no sqlite3.

Situação Ferramenta indicada Motivo
Só a contagem por status (um eixo) cut | sort | uniq -c Uma linha. Sem etapa de importação
Contagem por hora × status, listada na vertical Junte as chaves no awk e use sort | uniq -c Juntar duas chaves gera uma lista vertical
ok / errors / total lado a lado por hora (tabulação cruzada) sqlite3 Basta adicionar SUM(condição) como colunas
Cruzar IPs com uma tabela de países (junção) sqlite3 O JOIN liga as duas tabelas
Taxa de erro por IP (proporção) sqlite3 Contagem e contagem condicional ficam lado a lado
Consultar os mesmos dados várias vezes sqlite3 Importa uma vez; depois só o SQL muda
Ver só uma parte de um log de vários GB Filtre antes com grep Reduzir as linhas antes da importação é mais rápido

São três critérios: você quer vários valores agregados lado a lado em uma linha? Precisa cruzar com outra tabela? Precisa de uma proporção? Se qualquer um se aplicar, use SQL.

Tabulação cruzada: uma contagem que coloca o primeiro eixo (hora) nas linhas e o segundo eixo (ok / errors etc.) nas colunas, com as contagens nos cruzamentos.

Preparação: crie um access.log de exemplo

Conclusão: Pratique com um access.log pequeno do nginx feito à mão. Logs no formato combined padrão seguem os mesmos passos.

Crie o access.log de 18 linhas com o comando a seguir. Os endereços IP vêm de faixas reservadas para documentação (RFC 5737).

$ cat > access.log <<'EOF'
203.0.113.10 - - [24/Oct/2026:09:02:11 +0900] "GET / HTTP/1.1" 200 5120 "-" "Mozilla/5.0"
203.0.113.10 - - [24/Oct/2026:09:02:12 +0900] "GET /css/main.css HTTP/1.1" 200 1840 "-" "Mozilla/5.0"
198.51.100.7 - - [24/Oct/2026:09:15:40 +0900] "GET /login HTTP/1.1" 200 2310 "-" "curl/8.5.0"
198.51.100.7 - - [24/Oct/2026:09:15:41 +0900] "POST /login HTTP/1.1" 500 512 "-" "curl/8.5.0"
192.0.2.44 - - [24/Oct/2026:09:48:03 +0900] "GET /docs HTTP/1.1" 404 320 "-" "Mozilla/5.0"
203.0.113.10 - - [24/Oct/2026:10:05:27 +0900] "GET /api/items HTTP/1.1" 200 9800 "-" "Mozilla/5.0"
203.0.113.25 - - [24/Oct/2026:10:06:02 +0900] "GET / HTTP/1.1" 304 0 "-" "Mozilla/5.0"
198.51.100.7 - - [24/Oct/2026:10:11:19 +0900] "POST /login HTTP/1.1" 500 512 "-" "curl/8.5.0"
198.51.100.7 - - [24/Oct/2026:10:11:20 +0900] "POST /login HTTP/1.1" 500 512 "-" "curl/8.5.0"
192.0.2.44 - - [24/Oct/2026:10:30:45 +0900] "GET /docs/install HTTP/1.1" 200 7200 "-" "Mozilla/5.0"
203.0.113.25 - - [24/Oct/2026:10:31:10 +0900] "GET /api/items HTTP/1.1" 200 9800 "-" "Mozilla/5.0"
203.0.113.10 - - [24/Oct/2026:10:42:33 +0900] "GET /favicon.ico HTTP/1.1" 404 150 "-" "Mozilla/5.0"
198.51.100.99 - - [24/Oct/2026:13:01:05 +0900] "GET /wp-login.php HTTP/1.1" 404 150 "-" "python-requests/2.31"
198.51.100.99 - - [24/Oct/2026:13:01:06 +0900] "GET /.env HTTP/1.1" 404 150 "-" "python-requests/2.31"
203.0.113.10 - - [24/Oct/2026:13:20:14 +0900] "GET /api/items HTTP/1.1" 200 9800 "-" "Mozilla/5.0"
192.0.2.44 - - [24/Oct/2026:13:22:50 +0900] "GET / HTTP/1.1" 200 5120 "-" "Mozilla/5.0"
203.0.113.25 - - [24/Oct/2026:13:40:09 +0900] "GET /api/items HTTP/1.1" 500 512 "-" "Mozilla/5.0"
203.0.113.10 - - [24/Oct/2026:13:55:31 +0900] "GET /css/main.css HTTP/1.1" 304 0 "-" "Mozilla/5.0"
EOF
$ wc -l access.log
18 access.log

Colocar o marcador final entre aspas, como em <<'EOF', impede que o shell expanda $ e " no texto, que vai para o arquivo exatamente como está. Confirme com wc -l que ele tem 18 linhas.

Comece com uma contagem de um eixo pelo método de sempre. O 9º campo separado por espaço é o código de status.

$ cut -d' ' -f9 access.log | sort | uniq -c | sort -rn
      8 200
      4 500
      4 404
      2 304

Para um eixo, isso basta. Daqui em diante, o SQL cuida das contagens que ficam difíceis de escrever nesse formato.

Passo 1: Converta o access.log em CSV com awk

Conclusão: Extraia com awk só os campos necessários e gere um CSV com os nomes das colunas na linha 1. Esses nomes viram os nomes das colunas da tabela.

$ awk 'BEGIN { OFS = ","; print "ip,time,method,path,status,bytes" }
{ sub(/^\[/, "", $4); sub(/^"/, "", $6); print $1, $4, $6, $7, $9, $10 }' access.log > access.csv
$ head -n 4 access.csv
ip,time,method,path,status,bytes
203.0.113.10,24/Oct/2026:09:02:11,GET,/,200,5120
203.0.113.10,24/Oct/2026:09:02:12,GET,/css/main.css,200,1840
198.51.100.7,24/Oct/2026:09:15:40,GET,/login,200,2310
  • $1 / $4 / $6 / $7 / $9 / $10: os campos 1, 4, 6, 7, 9 e 10 separados por espaço. Na ordem: IP, horário, método, caminho, status e bytes
  • OFS = ",": define a vírgula como separador de saída
  • print "ip,time,...": imprime a linha de cabeçalho uma única vez no bloco BEGIN
  • sub(/^\[/, "", $4): remove o [ do início do horário. sub(/^"/, "", $6) remove o " do início do método

Confira também a quantidade de linhas. O CSV tem uma linha a mais por causa do cabeçalho.

$ wc -l access.log access.csv
  18 access.log
  19 access.csv
  37 total

Se algum caminho do log tiver vírgula, este comando awk desloca as colunas. Veja "O número de colunas não bate" em Solução de problemas.

Passo 2: Carregue o CSV em uma tabela com .import

Conclusão: Uma linha com .import --csv access.csv access cria a tabela. A linha 1 do CSV vira os nomes das colunas e não entra como dado.

$ sqlite3 logs.db ".import --csv access.csv access"
$ sqlite3 -csv logs.db "SELECT COUNT(*) FROM access;"
18
  • logs.db: o arquivo de banco de dados. Um único arquivo guarda todas as tabelas; ele é criado se não existir
  • ".import --csv access.csv access": lê access.csv como CSV e carrega os dados em uma tabela chamada access. Não mostra nada quando dá certo
  • -csv: mostra os resultados em formato CSV. O SELECT da segunda linha é o SQL que conta as linhas

Segundo a documentação oficial, se a tabela de destino ainda não existe, o .import cria a tabela e usa a primeira linha do CSV como nomes das colunas. Só as linhas 2 em diante viram dados, por isso a contagem é 18, igual ao número de linhas do access.log. Importar duas vezes na mesma tabela dobra a contagem; para refazer, apague a tabela antes, como mostra a Solução de problemas.

Mesmo com linhas com número errado de colunas ou linhas em branco, o .import mostra um aviso, continua a importação até o fim e termina com código de saída 0 (sucesso). Se aparecer uma única linha de aviso que seja, consulte a Solução de problemas.

Agora confira os tipos das colunas. pragma_table_info('tabela') é uma função embutida do SQLite que lista os nomes e os tipos das colunas de uma tabela. -header é a opção que coloca os nomes das colunas na primeira linha do resultado.

$ sqlite3 -csv -header logs.db "SELECT name, type FROM pragma_table_info('access');"
name,type
ip,TEXT
time,TEXT
method,TEXT
path,TEXT
status,TEXT
bytes,TEXT

Todas as colunas criadas automaticamente são TEXT (texto). Números como status e bytes também são guardados como texto. No código-fonte da 3.53.0 em diante (zAutoColumn em src/shell.c.in), a tabela é criada sem tipos de coluna, então o campo type fica vazio, mas os valores continuam sendo guardados como texto. Quando precisar de números, converta, por exemplo com CAST(bytes AS INTEGER).

No modo interativo (depois de iniciar sqlite3 logs.db), rodar .mode csv e depois .import access.csv access dá o mesmo resultado. Sem --csv, o .import lê o arquivo com os separadores do modo de saída atual.

Passo 3: Conte por status e por IP com GROUP BY

Conclusão: GROUP BY coluna junta as linhas de mesmo valor e COUNT(*) conta. ORDER BY count DESC LIMIT n mostra só os primeiros.

O SQL deste artigo é montado com as peças a seguir. Leia da esquerda para a direita e você acompanha do mesmo jeito que um pipe.

Peça Significado Equivalente no pipe
SELECT coluna, ... Escolhe as colunas da saída cut -f
FROM tabela De qual tabela ler Arquivo de entrada
WHERE condição Mantém só as linhas que atendem à condição grep
GROUP BY coluna Junta as linhas de mesmo valor em uma sort | uniq
COUNT(*) Número de linhas juntadas O número do uniq -c
ORDER BY coluna DESC Ordena (DESC é decrescente) sort -rn
LIMIT n Mostra só as n primeiras linhas head -n
$ sqlite3 -csv -header logs.db "SELECT status, COUNT(*) AS count FROM access GROUP BY status ORDER BY count DESC, status;"
status,count
200,8
404,4
500,4
304,2

As contagens são as mesmas do cut | sort | uniq -c. O AS em COUNT(*) AS count dá o nome count à coluna do resultado. Esse nome também pode ser usado no ORDER BY. A segunda chave status em ORDER BY count DESC, status fixa a ordem das linhas com a mesma contagem. Sem ela, a ordem das linhas empatadas não é garantida.

Os 3 endereços IP com mais acessos seguem o mesmo formato.

$ sqlite3 -csv -header logs.db "SELECT ip, COUNT(*) AS count FROM access GROUP BY ip ORDER BY count DESC, ip LIMIT 3;"
ip,count
203.0.113.10,6
198.51.100.7,4
192.0.2.44,3

Para contar por hora, recorte com substr os dois caracteres da hora no texto do horário e agrupe por eles. Em 24/Oct/2026:09:02:11, a hora são os 2 caracteres a partir da posição 13.

$ sqlite3 -csv -header logs.db "SELECT substr(time, 13, 2) AS hour, COUNT(*) AS count FROM access GROUP BY hour ORDER BY hour;"
hour,count
09,5
10,7
13,6

Passo 4: Conte em dois eixos: hora × status

Conclusão: Adicione o segundo eixo como coluna com SUM(condição). Verdadeiro vale 1 e falso vale 0, então a soma é a contagem condicional.

Se você só precisa das combinações de hora e status listadas na vertical, um pipe resolve. Junte as duas chaves em uma linha com awk e conte com sort | uniq -c. O $4 começa com [, então a hora são os 2 caracteres a partir da posição 14.

$ awk '{ print substr($4, 14, 2), $9 }' access.log | sort | uniq -c
      3 09 200
      1 09 404
      1 09 500
      3 10 200
      1 10 304
      1 10 404
      2 10 500
      2 13 200
      1 13 304
      2 13 404
      1 13 500

Mas, se você quer ver "ok", "errors" e "total" lado a lado em uma linha por hora (tabulação cruzada), essa saída ainda precisa ser reorganizada. No SQL, basta adicionar colunas.

$ sqlite3 -csv -header logs.db "SELECT substr(time, 13, 2) AS hour, SUM(status = '200') AS ok, SUM(CAST(status AS INTEGER) >= 400) AS errors, COUNT(*) AS total FROM access GROUP BY hour ORDER BY hour;"
hour,ok,errors,total
09,3,2,5
10,3,3,7
13,2,3,6
  • status = '200': status é texto, então escreva o valor comparado também como texto entre aspas, '200'
  • CAST(status AS INTEGER) >= 400: para comparar faixas, converta para número antes
  • ok + errors nem sempre é igual a total, porque o 304 (em cache) não atende a nenhuma das duas condições

A taxa de erro (proporção) por IP segue a mesma ideia.

$ sqlite3 -csv -header logs.db "SELECT ip, COUNT(*) AS total, SUM(CAST(status AS INTEGER) >= 400) AS errors, ROUND(100.0 * SUM(CAST(status AS INTEGER) >= 400) / COUNT(*), 1) AS error_pct FROM access GROUP BY ip ORDER BY error_pct DESC, total DESC, ip;"
ip,total,errors,error_pct
198.51.100.99,2,2,100.0
198.51.100.7,4,3,75.0
192.0.2.44,3,1,33.3
203.0.113.25,3,1,33.3
203.0.113.10,6,1,16.7

A multiplicação usa 100.0 * porque a divisão entre dois inteiros descarta a parte decimal. O 198.51.100.7 recebeu 500 do POST /login três vezes seguidas. Uma contagem de um eixo não revela um desvio assim.

Passo 5: Junte com uma tabela de países usando JOIN

Conclusão: Carregue a tabela de correspondência como outra tabela e ligue a coluna em comum com JOIN ... ON. Linhas sem correspondência aparecem com LEFT JOIN.

Crie countries.csv, uma tabela de correspondência entre endereço IP e código de país, com um here-document, como no access.log.

$ cat > countries.csv <<'EOF'
ip,country
203.0.113.10,JP
203.0.113.25,JP
198.51.100.7,US
192.0.2.44,BR
EOF
$ wc -l countries.csv
5 countries.csv

Carregue no mesmo banco de dados como segunda tabela e ligue as duas pela coluna ip.

$ sqlite3 logs.db ".import --csv countries.csv countries"
$ sqlite3 -csv -header logs.db "SELECT c.country, COUNT(*) AS count FROM access AS a JOIN countries AS c ON a.ip = c.ip GROUP BY c.country ORDER BY count DESC;"
country,count
JP,9
US,4
BR,3

O total é 16, ou seja, 2 a menos que os 18 do conjunto. O JOIN só mantém as linhas que têm correspondência nas duas tabelas, então as linhas com IP ausente da tabela de países ficaram de fora. Use LEFT JOIN para encontrá-las.

$ sqlite3 -csv -header logs.db "SELECT a.ip, COUNT(*) AS count FROM access AS a LEFT JOIN countries AS c ON a.ip = c.ip WHERE c.ip IS NULL GROUP BY a.ip;"
ip,count
198.51.100.99,2

O LEFT JOIN mantém todas as linhas da tabela da esquerda (access) e preenche as colunas da tabela da direita com NULL (sem valor) quando não há correspondência. O WHERE c.ip IS NULL separa só essas linhas "sem correspondência".

Chaves duplicadas na tabela de correspondência aumentam a contagem

Se a tabela de correspondência tiver o mesmo ip duas vezes, o JOIN duplica cada linha correspondente pelo número de linhas do outro lado. Nenhum erro aparece, então a contagem cresce sem aviso. Ao juntar com uma tabela que tem uma linha extra de 203.0.113.10, o JP passa de 9 para 15.

$ cp countries.csv countries2.csv
$ echo '203.0.113.10,JP' >> countries2.csv
$ sqlite3 dup.db ".import --csv access.csv access"
$ sqlite3 dup.db ".import --csv countries2.csv countries"
$ sqlite3 -csv -header dup.db "SELECT c.country, COUNT(*) AS count FROM access AS a JOIN countries AS c ON a.ip = c.ip GROUP BY c.country ORDER BY count DESC;"
country,count
JP,15
US,4
BR,3

Antes de juntar, verifique se há chaves duplicadas na tabela de correspondência. HAVING é a cláusula que filtra os grupos depois do GROUP BY (a versão do WHERE para grupos).

$ sqlite3 -csv -header dup.db "SELECT ip, COUNT(*) AS n FROM countries GROUP BY ip HAVING n > 1;"
ip,n
203.0.113.10,2

Rodar o mesmo SQL no logs.db original não mostra nada, o que indica que não há duplicatas.

Passo 6: Gere CSV e devolva para o sort

Conclusão: A saída de sqlite3 -csv é um CSV comum, então pode ir direto para sort ou head. Remova antes o \r do fim das linhas.

$ sqlite3 -csv logs.db "SELECT path, SUM(CAST(bytes AS INTEGER)) FROM access GROUP BY path;" | tr -d '\r' | sort -t, -k2,2nr | head -n 3
/api/items,29912
/,10240
/docs/install,7200
  • Sem -header, a linha de cabeçalho não aparece, então a saída pode ir direto para o sort
  • sort -t, -k2,2nr: ordena pela 2ª coluna separada por vírgula, como número, em ordem decrescente
  • tr -d '\r': a documentação oficial da série 3.54 (o guia dos formatos de saída da CLI) diz que a saída CSV sempre termina as linhas com CRLF (\r\n). Já no 3.45.1, o -csv terminava as linhas só com LF (conferido com od -c). Como o fim de linha muda conforme a versão, o comando garante o mesmo resultado nas duas

Deixe a agregação para o SQL e a ordenação e a formatação para os comandos de sempre. Com essa divisão, os passos se encaixam com facilidade em scripts de shell que você já tem.

Por que SQL para dois eixos ou mais: comparação com awk

Conclusão: O awk também faz a mesma agregação, mas cada eixo ou tabela a mais traz mais arrays e construções especiais. No SQL, basta acrescentar colunas e condições.

Esta é a "taxa de erro por IP" do Passo 4 escrita em awk.

$ awk -F, 'NR > 1 { total[$1]++; if ($5 >= 400) err[$1]++ }
END { for (ip in total) printf "%s,%d,%d,%.1f\n", ip, total[ip], err[ip], 100 * err[ip] / total[ip] }' access.csv | sort -t, -k4,4nr -k2,2nr
198.51.100.99,2,2,100.0
198.51.100.7,4,3,75.0
192.0.2.44,3,1,33.3
203.0.113.25,3,1,33.3
203.0.113.10,6,1,16.7

O resultado é o mesmo. Mas quem lê precisa acompanhar de cabeça os pontos a seguir.

  • Há dois arrays (total e err), e o END os cruza supondo que as chaves batem
  • A ordem de for (ip in total) não é definida, então um sort no final é obrigatório
  • As colunas são indicadas por número ($1 / $5); se a ordem das colunas do CSV mudar, o comando quebra sem avisar

A junção do Passo 5 fica ainda mais difícil de ler em awk.

$ awk -F, 'NR == FNR { if (FNR > 1) country[$1] = $2; next }
FNR > 1 && ($1 in country) { n[country[$1]]++ }
END { for (c in n) print c "," n[c] }' countries.csv access.csv | sort -t, -k2,2nr
JP,9
US,4
BR,3

Quem não conhece a construção NR == FNR (verdadeira só enquanto o primeiro arquivo está sendo lido) não entende o que isso faz. Além disso, awk -F, não interpreta as aspas do CSV, então um único valor com vírgula, como "a,b", desloca as colunas. Já o .import segue a RFC 4180 (a especificação padrão do CSV) e remove as aspas ao ler.

No SQL, "por qual coluna agrupar (GROUP BY)", "o que contar (COUNT / SUM)" e "quais tabelas ligar (JOIN)" aparecem escritos por nome. Para acrescentar mais um eixo, basta adicionar um nome de coluna ao GROUP BY e ao SELECT.

E se o COUNT(*) não bater com o wc -l?

Conclusão: A diferença costuma vir da linha de cabeçalho, de linhas em branco ou de quebras de linha dentro de aspas. Procure as linhas em branco no log original, não no CSV.

Causa Como a contagem difere Como verificar
Linha de cabeçalho wc -l access.csv dá 1 a mais que COUNT(*) Normal. Com tabela nova, o .import usa a linha 1 como nomes
Linhas em branco COUNT(*) dá mais que as linhas do log original grep -c '^$' access.log
Quebra de linha dentro de aspas wc -l dá mais que COUNT(*) Um registro ocupa várias linhas. wc -l só conta quebras
Um segundo .import COUNT(*) quase dobra SELECT COUNT(*) FROM access WHERE ip = 'ip';

Verifique as linhas em branco no log original.

$ grep -c '^$' access.log
0

Rodar o grep no CSV não encontra linhas em branco. O awk do Passo 1 também processa a linha em branco e imprime uma linha com as 6 colunas vazias: ,,,,,. Confira com um log que tem uma linha em branco a mais no final.

$ cp access.log blank.log
$ echo '' >> blank.log
$ awk 'BEGIN { OFS = ","; print "ip,time,method,path,status,bytes" }
{ sub(/^\[/, "", $4); sub(/^"/, "", $6); print $1, $4, $6, $7, $9, $10 }' blank.log > blank.csv
$ tail -n 1 blank.csv
,,,,,

Essa linha tem o número certo de colunas, então o .import a carrega sem aviso. Depois da importação, conte as linhas cujo status é texto vazio ou NULL para encontrá-las.

$ sqlite3 blank.db ".import --csv blank.csv access"
$ sqlite3 -csv blank.db "SELECT COUNT(*) FROM access WHERE status = '' OR status IS NULL;"
1

Se um CSV feito sem awk tiver uma linha em branco, o .import mostra o aviso expected 6 columns but found 1 - filling the rest with NULL e carrega uma linha com as demais colunas em NULL. O mesmo SQL conta os dois casos.

Para prevenir, coloque a condição NF >= 10 antes do corpo do awk do Passo 1. Só as linhas com 10 campos ou mais viram CSV, então linhas em branco e linhas cortadas não são impressas.

$ awk 'BEGIN { OFS = ","; print "ip,time,method,path,status,bytes" }
NF >= 10 { sub(/^\[/, "", $4); sub(/^"/, "", $6); print $1, $4, $6, $7, $9, $10 }' blank.log | wc -l
19

Saíram só a linha de cabeçalho e as 18 linhas sem a linha em branco. Confirme também que o logs.db deste artigo não tem linhas vazias.

$ sqlite3 -csv logs.db "SELECT COUNT(*) FROM access WHERE status = '' OR status IS NULL;"
0

Solução de problemas

Conclusão: A maioria dos problemas de importação se explica por cinco pontos: colunas, tipos, formato de data, importação dupla e tamanho.

Sintoma: O número de colunas não bate (expected 6 columns but found 7)

Causa: Um caminho ou uma query string tem vírgula, e o CSV gerado pelo awk ganha uma coluna a mais.

Verificação:

$ printf '%s\n' '192.0.2.44 - - [24/Oct/2026:14:03:12 +0900] "GET /search?q=sqlite,csv HTTP/1.1" 200 2048 "-" "Mozilla/5.0"' > comma.log
$ awk 'BEGIN { OFS = "," } { sub(/^\[/, "", $4); sub(/^"/, "", $6); print $1, $4, $6, $7, $9, $10 }' comma.log | awk -F, '{ print NF }'
7

Deveriam ser 6 colunas, mas são 7. Se você importar assim, o .import descarta a coluna excedente com um aviso, e os valores se deslocam uma coluna.

$ awk 'BEGIN { OFS = ","; print "ip,time,method,path,status,bytes" } { sub(/^\[/, "", $4); sub(/^"/, "", $6); print $1, $4, $6, $7, $9, $10 }' access.log comma.log > bad.csv
$ sqlite3 bad.db ".import --csv bad.csv access"
$ sqlite3 -csv -header bad.db "SELECT path, status, bytes FROM access WHERE time LIKE '%14:03%';"
bad.csv:20: expected 6 columns but found 7 - extras ignored
path,status,bytes
/search?q=sqlite,csv,200

A primeira linha é o aviso do .import (a linha 20 tinha 7 colunas, então a excedente foi descartada). No resultado, path ficou /search?q=sqlite, status ficou csv e bytes ficou 200. Parte do caminho foi parar na coluna status.

Solução: Coloque entre " as colunas que podem conter vírgula. Troque qualquer " dentro do valor por "" (a regra da RFC 4180).

$ awk 'BEGIN { OFS = "," } { sub(/^\[/, "", $4); sub(/^"/, "", $6); gsub(/"/, "\"\"", $7); print $1, $4, $6, "\"" $7 "\"", $9, $10 }' comma.log
192.0.2.44,24/Oct/2026:14:03:12,GET,"/search?q=sqlite,csv",200,2048

Converta access.log e comma.log juntos para um CSV nesse formato e carregue.

$ awk 'BEGIN { OFS = ","; print "ip,time,method,path,status,bytes" }
{ sub(/^\[/, "", $4); sub(/^"/, "", $6); gsub(/"/, "\"\"", $7)
  print $1, $4, $6, "\"" $7 "\"", $9, $10 }' access.log comma.log > access_q.csv
$ sqlite3 logs2.db ".import --csv access_q.csv access"
$ sqlite3 -csv -header logs2.db "SELECT path, status FROM access WHERE path LIKE '%,%';"
path,status
"/search?q=sqlite,csv",200

O valor entra como um só, com a vírgula preservada, e a coluna status fica corretamente com 200. Na saída, valores com vírgula também aparecem entre ".

Sintoma: Os números saem em uma ordem estranha (1840 depois de 150)

Causa: As colunas criadas automaticamente pelo .import são texto (TEXT), então ORDER BY bytes usa a ordem de dicionário (compara um caractere por vez, da esquerda para a direita).

Verificação:

$ sqlite3 -csv logs.db "SELECT DISTINCT bytes FROM access ORDER BY bytes;" | tr -d '\r' | paste -sd' '
0 150 1840 2310 320 512 5120 7200 9800

Solução: Converta para número com CAST(coluna AS INTEGER) antes de ordenar, comparar ou somar.

$ sqlite3 -csv logs.db "SELECT DISTINCT bytes FROM access ORDER BY CAST(bytes AS INTEGER);" | tr -d '\r' | paste -sd' '
0 150 320 512 1840 2310 5120 7200 9800

Se escrever CAST toda vez for trabalhoso, você pode criar antes uma tabela com os tipos definidos e depois importar nela. Quando a tabela já existe, a linha 1 também é tratada como dado, por isso o --skip 1 pula a linha de cabeçalho. Faça o teste em um arquivo separado, typed.db, para não conflitar com o logs.db existente.

$ sqlite3 typed.db "CREATE TABLE access(ip TEXT, time TEXT, method TEXT, path TEXT, status INTEGER, bytes INTEGER);"
$ sqlite3 typed.db ".import --csv --skip 1 access.csv access"
$ sqlite3 -csv typed.db "SELECT DISTINCT bytes FROM access ORDER BY bytes;" | tr -d '\r' | paste -sd' '
0 150 320 512 1840 2310 5120 7200 9800

Os valores saem em ordem numérica sem CAST, porque uma coluna declarada como INTEGER guarda como número os textos formados só por dígitos.

Sintoma: date() ou strftime() devolve vazio (NULL)

Causa: 24/Oct/2026:09:02:11 não é um formato que as funções de data do SQLite conseguem ler. A data está guardada apenas como texto.

Verificação:

$ sqlite3 -csv -header logs.db "SELECT time, date(time) AS d FROM access LIMIT 1;"
time,d
24/Oct/2026:09:02:11,

O d está vazio.

Solução: Converta o horário para o formato 2026-10-24 09:02:11 (ISO 8601) ao gerar o CSV. Monte no BEGIN uma tabela que troca o nome do mês pelo número.

$ awk 'BEGIN { OFS = ","; split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", m, " ")
  for (i = 1; i <= 12; i++) mon[m[i]] = sprintf("%02d", i)
  print "ip,time,method,path,status,bytes" }
{ split(substr($4, 2), t, /[\/:]/)
  iso = t[3] "-" mon[t[2]] "-" t[1] " " t[4] ":" t[5] ":" t[6]
  sub(/^"/, "", $6); print $1, iso, $6, $7, $9, $10 }' access.log > access_iso.csv
$ head -n 2 access_iso.csv
ip,time,method,path,status,bytes
203.0.113.10,2026-10-24 09:02:11,GET,/,200,5120

Nesse formato, strftime('%H', time) e date(time) funcionam diretamente. Carregue em um arquivo separado, iso.db, para conferir.

$ sqlite3 iso.db ".import --csv access_iso.csv access"
$ sqlite3 -csv -header iso.db "SELECT date(time) AS day, COUNT(*) AS count FROM access GROUP BY day;"
day,count
2026-10-24,18

Sintoma: A contagem dobrou / a linha de cabeçalho se misturou aos dados

Causa: Você rodou o .import duas vezes na mesma tabela. Quando a tabela já existe, o .import adiciona todas as linhas como dados, inclusive a linha 1 (é o comportamento descrito na documentação oficial).

Verificação: Procure linhas cujo valor seja igual ao nome da coluna.

$ sqlite3 -csv logs.db "SELECT COUNT(*) FROM access WHERE ip = 'ip';"
0

Se o resultado for 1 ou mais, a linha de cabeçalho está nos dados. Importar mais uma vez em uma cópia do logs.db (twice.db) dá a seguinte contagem e linhas de cabeçalho.

$ cp logs.db twice.db
$ sqlite3 twice.db ".import --csv access.csv access"
$ sqlite3 -csv twice.db "SELECT COUNT(*) FROM access;"
$ sqlite3 -csv twice.db "SELECT COUNT(*) FROM access WHERE ip = 'ip';"
37
1

As 19 linhas, incluindo a de cabeçalho, foram somadas às 18 originais, totalizando 37.

Solução: Apague a tabela e importe de novo.

$ sqlite3 twice.db "DROP TABLE IF EXISTS access;"
$ sqlite3 twice.db ".import --csv access.csv access"
$ sqlite3 -csv twice.db "SELECT COUNT(*) FROM access;"
18

Sintoma: Importar um log enorme demora muito

Causa: Você está convertendo e importando linhas de que não precisa.

Solução: Filtre as linhas com grep antes da importação. Por exemplo, para olhar só as respostas 4xx / 5xx:

$ grep -E '" [45][0-9]{2} ' access.log | wc -l
8

As 18 linhas caíram para 8. Passe essa saída do grep para o awk do Passo 1 para gerar o CSV, e a importação diminui na mesma proporção.

Se a origem do .import começar com |, ela é a saída de um comando (por exemplo, .import --csv '|grep -v "^$" access.csv' access). Assim você filtra sem criar arquivo temporário.

Checklist de Conclusão

Conclusão: Antes de agregar, confira a contagem logo após a importação e o tratamento das colunas de número e de data, um item por vez.

  • [ ] O wc -l access.log (o número de linhas do log original) bateu com o SELECT COUNT(*)
  • [ ] O grep -c '^$' access.log encontrou 0 linhas em branco (ou o awk usa NF >= 10)
  • [ ] O .import não mostrou nenhum aviso
  • [ ] A tabela de correspondência usada na junção não tem chaves duplicadas (GROUP BY ... HAVING n > 1 vem vazio)
  • [ ] As colunas que podem conter vírgula foram colocadas entre " no CSV
  • [ ] As colunas ordenadas ou comparadas como número usam CAST(... AS INTEGER)
  • [ ] Nas contagens por data, o horário foi convertido para ISO 8601 ao gerar o CSV
  • [ ] As contagens que podem empatar têm uma segunda chave no ORDER BY
  • [ ] A saída devolvida para o pipe passa por tr -d '\r'

Resumo

Conclusão: Um eixo fica com o pipe; dois eixos ou mais e junções ficam com o SQL. Gere o CSV com awk, carregue com .import e devolva o resultado ao pipe com -csv.

Situação Comando / sintaxe Objetivo
Contagem de um eixo cut | sort | uniq -c | sort -rn Contar na hora, sem importar
Converter para CSV awk 'BEGIN { OFS = "," } ...' Manter só as colunas necessárias em CSV
Carregar sqlite3 logs.db ".import --csv FILE TABLE" Criar uma tabela a partir do CSV
Contar GROUP BY coluna + COUNT(*) Contar por valor
Contagem condicional e proporção SUM(condição) / 100.0 * SUM(condição) / COUNT(*) Segundo eixo e taxa de erro
Juntar tabelas JOIN ... ON / LEFT JOIN ... WHERE ... IS NULL Ligar uma tabela de correspondência e achar lacunas
Devolver ao pipe sqlite3 -csv DB "SQL" | tr -d '\r' | sort Deixar ordenação e formatação para o pipe

Próximas Leituras

Compartilhe este artigo

Próximo passo