Projeto: da Planilha ao Banco

A Norte Suprimentos, fornecedora da Loja Aurora, mandou por email a planilha dos produtos que vai começar a vender: oito linhas, salvas como CSV. A Marta quer os produtos cadastrados no banco até o fim do dia. Digitar um por um? Nem pensar. Neste projeto a Ana leva a planilha do arquivo até a tabela produtos, no terminal do sqlite3: importa, descobre que veio tudo bagunçado, limpa com SQL, converte os tipos, insere e, no fim, exporta um CSV de volta para conferência. É o trabalho mais comum do mundo com bancos de dados, e quase nunca dá certo de primeira.

Você vai aprender
  • O que é um arquivo CSV e como criá-lo e conferi-lo no terminal.
  • A importar com .import --csv, e por que tudo chega como texto.
  • A limpar os problemas de toda planilha: espaços sobrando, vírgula decimal, células vazias, linhas repetidas, nomes que não batem.
  • A converter os tipos com CAST, REPLACE, TRIM e NULLIF.
  • A levar os dados limpos para a tabela de verdade com INSERT INTO … SELECT.
  • A exportar de volta para CSV com .headers, .mode csv e .once.

Este projeto acontece em dois lugares. A importação e a exportação são comandos de ponto do programa sqlite3 (aula Comandos de Ponto), que só existem no terminal: os quadros pretos mostram a sessão real da Ana, no computador dela. A limpeza é SQL puro, e essa você pratica no editor do guia, em cada versão. A aula Importar e Exportar CSV explica os comandos em detalhe; aqui eles trabalham juntos num caso real.


A ideia: CSV é uma planilha em texto puro

CSV quer dizer comma-separated values, "valores separados por vírgula". É uma planilha sem cores nem fórmulas: cada linha do arquivo é uma linha da tabela, e as colunas são separadas por vírgula. A primeira linha, quase sempre, traz os nomes das colunas. Quando um valor tem vírgula dentro (como "6,90" ou "Caderno Brochura, 48 folhas"), ele vem entre aspas duplas, para a vírgula de dentro não ser confundida com a que separa colunas.

Qualquer planilha (LibreOffice, Excel, Google Planilhas) salva e abre CSV. É por isso que ele é a "língua franca" entre planilhas e bancos de dados.


Versão 1: o arquivo

A Ana salvou o anexo do email. Para você poder repetir tudo no seu computador, aqui está o arquivo criado direto no terminal, com cat e um here-document (tudo entre << 'FIM' e a linha FIM vai para o arquivo):

$ cat > novos-produtos.csv << 'FIM'
> nome,categoria,fornecedor,preco,estoque
> Caneta Gel Preta,Papelaria,Norte Suprimentos,"3,49",200
>   Borracha Branca ,Papelaria,Norte Suprimentos,"1,20",350
> "Caderno Brochura, 48 folhas",Papelaria,Norte Suprimentos,"6,90",150
> Bola de Futebol,Esporte e Lazer,Norte Suprimentos,"59,90",
> Peteca,esporte e lazer,Norte Suprimentos,"12,50",40
> Pá de Jardinagem,Jardim,Norte Suprimentos,"24,00",25
> Caneta Gel Preta,Papelaria,Norte Suprimentos,"3,49",200
> Ioiô Colorido,Brinquedo,Norte Suprimentos,"8,75",60
> FIM
$ wc -l novos-produtos.csv
9 novos-produtos.csv                   « 1 cabeçalho + 8 produtos
$ head -3 novos-produtos.csv
nome,categoria,fornecedor,preco,estoque
Caneta Gel Preta,Papelaria,Norte Suprimentos,"3,49",200
  Borracha Branca ,Papelaria,Norte Suprimentos,"1,20",350

Olhe o arquivo com olhos de detetive antes de importar. Uma planilha feita por gente traz problemas que o banco não vai perdoar:

LinhaO problemaPor que atrapalha
todaspreço com vírgula decimal: "3,49"Para o SQL, o separador decimal é o ponto. 3,49 não é um número.
Borrachaespaços antes e depois do nome' Borracha Branca ' é diferente de 'Borracha Branca' numa busca.
Cadernovírgula dentro do nome, entre aspasSó um leitor de CSV de verdade entende que é uma coluna só.
Bolaestoque vazioVazio não é zero, nem NULL: é um texto vazio.
Petecacategoria em minúsculas'esporte e lazer' não é igual a 'Esporte e Lazer'.
Caneta (2ª vez)linha repetidaViraria dois produtos iguais.
Ioiôcategoria que não existe: "Brinquedo"Na Aurora, a categoria é "Brinquedos", no plural.

A estratégia profissional é sempre a mesma: importar primeiro para uma tabela de passagem (em inglês, staging), do jeito que vier; limpar e conferir lá, com SQL; e só então copiar para a tabela de verdade. A tabela produtos nunca vê a bagunça.


Versão 2: importar para a tabela de passagem

A Ana abre o banco da loja e importa:

$ sqlite3 loja.db
SQLite version 3.50.4 2025-07-30 19:33:53
Enter ".help" for usage hints.
sqlite> .import --csv novos-produtos.csv importados
sqlite> .schema importados
CREATE TABLE IF NOT EXISTS "importados"(
"nome" TEXT, "categoria" TEXT, "fornecedor" TEXT, "preco" TEXT,
 "estoque" TEXT);
sqlite> SELECT COUNT(*) FROM importados;
8
.import
Comando de ponto do sqlite3: lê um arquivo e põe as linhas numa tabela. Em silêncio: se deu certo, não escreve nada.
--csv
Lê o arquivo como CSV de verdade: vírgula separa colunas, aspas protegem vírgulas de dentro. Sem esta opção, o resultado é um desastre (veja Quando dá errado).
novos-produtos.csv
O arquivo, na pasta atual.
importados
A tabela de destino. Como ela não existia, o sqlite3 criou uma, usando a primeira linha do arquivo como nomes das colunas. Por isso vieram 8 linhas, não 9: o cabeçalho virou nome de coluna.
.schema importados
Mostra o CREATE TABLE que o .import escreveu. Repare: todas as colunas são TEXT. O .import não adivinha tipos.

Agora, a tabela inteira. O modo box desenha uma moldura em volta de cada coluna, o que facilita ler tabelas no terminal:

sqlite> .mode box
sqlite> SELECT * FROM importados;
┌─────────────────────────────┬─────────────────┬───────────────────┬───────┬─────────┐
│            nome             │    categoria    │    fornecedor     │ preco │ estoque │
├─────────────────────────────┼─────────────────┼───────────────────┼───────┼─────────┤
│ Caneta Gel Preta            │ Papelaria       │ Norte Suprimentos │ 3,49  │ 200     │
│   Borracha Branca           │ Papelaria       │ Norte Suprimentos │ 1,20  │ 350     │
│ Caderno Brochura, 48 folhas │ Papelaria       │ Norte Suprimentos │ 6,90  │ 150     │
│ Bola de Futebol             │ Esporte e Lazer │ Norte Suprimentos │ 59,90 │         │
│ Peteca                      │ esporte e lazer │ Norte Suprimentos │ 12,50 │ 40      │
│ Pá de Jardinagem            │ Jardim          │ Norte Suprimentos │ 24,00 │ 25      │
│ Caneta Gel Preta            │ Papelaria       │ Norte Suprimentos │ 3,49  │ 200     │
│ Ioiô Colorido               │ Brinquedo       │ Norte Suprimentos │ 8,75  │ 60      │
└─────────────────────────────┴─────────────────┴───────────────────┴───────┴─────────┘
sqlite> SELECT nome, preco, typeof(preco), estoque, typeof(estoque) FROM importados WHERE rowid <= 4;
┌─────────────────────────────┬───────┬───────────────┬─────────┬─────────────────┐
│            nome             │ preco │ typeof(preco) │ estoque │ typeof(estoque) │
├─────────────────────────────┼───────┼───────────────┼─────────┼─────────────────┤
│ Caneta Gel Preta            │ 3,49  │ text          │ 200     │ text            │
│   Borracha Branca           │ 1,20  │ text          │ 350     │ text            │
│ Caderno Brochura, 48 folhas │ 6,90  │ text          │ 150     │ text            │
│ Bola de Futebol             │ 59,90 │ text          │         │ text            │
└─────────────────────────────┴───────┴───────────────┴─────────┴─────────────────┘
sqlite> SELECT preco * 2 FROM importados WHERE rowid = 1;
┌───────────┐
│ preco * 2 │
├───────────┤
│ 6         │
└───────────┘

O --csv fez o trabalho dele: o Caderno, com a vírgula no nome, ficou numa coluna só, e as aspas sumiram. Mas o typeof confirma: o preço 3,49 é texto, e o estoque 200 também. Nas contas, o SQLite aproveita só o começo do texto que parece número: '3,49' * 2 deu 6, porque ele leu o 3 e parou na vírgula (aula Os Tipos no SQLite). Sem erro, sem aviso: é o tipo de coisa que só aparece no fechamento do caixa.

No editor, a tabela de passagem já vem pronta

O editor do guia não tem comandos de ponto, então não roda .import. As caixas desta página criam a tabela importados exatamente como o .import a deixou: cinco colunas TEXT, com os textos do arquivo, espaços e vírgulas incluídos. O SQL de limpeza que você escrever ali funciona igual no terminal.

Missão: antes de limpar, a Ana quer saber se há produtos repetidos na planilha. Agrupe por nome e mostre só os que aparecem mais de uma vez, com o nome e quantas vezes (aulas GROUP BY e HAVING). Use TRIM(nome), para que espaços sobrando não escondam uma repetição.

-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('  Borracha Branca ',          'Papelaria',       'Norte Suprimentos', '1,20',  '350'),
('Caderno Brochura, 48 folhas', 'Papelaria',       'Norte Suprimentos', '6,90',  '150'),
('Bola de Futebol',             'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca',                      'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem',            'Jardim',          'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('Ioiô Colorido',               'Brinquedo',       'Norte Suprimentos', '8,75',  '60');
-- a consulta
SELECT nome, categoria, preco FROM importados;
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('  Borracha Branca ',          'Papelaria',       'Norte Suprimentos', '1,20',  '350'),
('Caderno Brochura, 48 folhas', 'Papelaria',       'Norte Suprimentos', '6,90',  '150'),
('Bola de Futebol',             'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca',                      'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem',            'Jardim',          'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('Ioiô Colorido',               'Brinquedo',       'Norte Suprimentos', '8,75',  '60');
-- a consulta
SELECT TRIM(nome) AS nome, COUNT(*) AS vezes
FROM importados
GROUP BY TRIM(nome)
HAVING COUNT(*) > 1;

A Caneta Gel Preta veio duas vezes, com todos os campos iguais: alguém copiou a linha duas vezes na planilha. A versão 5 resolve com DISTINCT.


Versão 3: limpar e converter

Cada problema da planilha tem uma função de texto que resolve (aula Funções de Texto). A Ana testa uma de cada vez, no terminal, antes de mudar qualquer coisa. O .nullvalue NULL do começo faz o NULL aparecer escrito na tela, para não ser confundido com um texto vazio:

sqlite> .nullvalue NULL
sqlite> SELECT '[' || nome || ']' AS nome, '[' || TRIM(nome) || ']' AS limpo FROM importados WHERE rowid = 2;
┌──────────────────────┬───────────────────┐
│         nome         │       limpo       │
├──────────────────────┼───────────────────┤
│ [  Borracha Branca ] │ [Borracha Branca] │
└──────────────────────┴───────────────────┘
sqlite> SELECT preco, REPLACE(preco, ',', '.') AS com_ponto, CAST(REPLACE(preco, ',', '.') AS REAL) AS numero FROM importados WHERE rowid <= 3;
┌───────┬───────────┬────────┐
│ preco │ com_ponto │ numero │
├───────┼───────────┼────────┤
│ 3,49  │ 3.49      │ 3.49   │
│ 1,20  │ 1.20      │ 1.2    │
│ 6,90  │ 6.90      │ 6.9    │
└───────┴───────────┴────────┘
sqlite> SELECT nome, estoque, typeof(estoque), NULLIF(estoque, '') AS sem_vazio FROM importados WHERE rowid = 4;
┌─────────────────┬─────────┬─────────────────┬───────────┐
│      nome       │ estoque │ typeof(estoque) │ sem_vazio │
├─────────────────┼─────────┼─────────────────┼───────────┤
│ Bola de Futebol │         │ text            │ NULL      │
└─────────────────┴─────────┴─────────────────┴───────────┘
'[' || nome || ']'
O || junta textos. Os colchetes são um truque para ver espaços, que na tela são invisíveis: [ Borracha Branca ] mostra dois espaços antes e um depois.
TRIM(nome)
Tira os espaços do começo e do fim (do meio, não).
REPLACE(preco, ',', '.')
Troca a vírgula pelo ponto. O resultado ainda é texto: '1.20', com o zero.
CAST(… AS REAL)
Converte o texto em número de verdade: 1.2. Agora as contas funcionam.
NULLIF(estoque, '')
"Se for igual a '', devolva NULL." O texto vazio vira "sem valor", que é o que ele quer dizer (aula O Valor NULL).

Missão: escreva a consulta de limpeza completa: as 8 linhas com três colunas chamadas nome (sem os espaços), preco (número REAL, com ponto) e estoque (número INTEGER, ou NULL quando vier vazio). Junte as funções de dentro para fora: CAST(NULLIF(TRIM(estoque), '') AS INTEGER).

-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('  Borracha Branca ',          'Papelaria',       'Norte Suprimentos', '1,20',  '350'),
('Caderno Brochura, 48 folhas', 'Papelaria',       'Norte Suprimentos', '6,90',  '150'),
('Bola de Futebol',             'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca',                      'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem',            'Jardim',          'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('Ioiô Colorido',               'Brinquedo',       'Norte Suprimentos', '8,75',  '60');
-- a consulta
SELECT nome, preco, estoque
FROM importados;
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('  Borracha Branca ',          'Papelaria',       'Norte Suprimentos', '1,20',  '350'),
('Caderno Brochura, 48 folhas', 'Papelaria',       'Norte Suprimentos', '6,90',  '150'),
('Bola de Futebol',             'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca',                      'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem',            'Jardim',          'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('Ioiô Colorido',               'Brinquedo',       'Norte Suprimentos', '8,75',  '60');
-- a consulta
SELECT TRIM(nome)                               AS nome,
       CAST(REPLACE(preco, ',', '.') AS REAL)     AS preco,
       CAST(NULLIF(TRIM(estoque), '') AS INTEGER) AS estoque
FROM importados;

Por que não CAST(estoque AS INTEGER) direto? Porque CAST('' AS INTEGER) dá 0, e um estoque "não informado" viraria "zero em estoque" sem ninguém decidir isso. Com o NULLIF antes, o vazio vira NULL, e a decisão fica para você (na versão 5, a Ana decide que é 0, de propósito, com COALESCE).


Versão 4: as categorias batem?

A tabela produtos não guarda o nome da categoria, e sim o categoria_id. Então cada texto da planilha tem de encontrar a sua categoria na tabela categorias da Aurora. Antes de inserir, a pergunta certa é: alguma não vai encontrar? É o LEFT JOIN … IS NULL de sempre (aula LEFT JOIN):

sqlite> SELECT DISTINCT i.categoria FROM importados i LEFT JOIN categorias c ON c.nome = i.categoria WHERE c.id IS NULL;
┌─────────────────┐
│    categoria    │
├─────────────────┤
│ esporte e lazer │
│ Brinquedo       │
└─────────────────┘

Duas categorias sem par. A "esporte e lazer" é só a caixa das letras: comparando com COLLATE NOCASE, o SQLite ignora maiúsculas e minúsculas, e ela encontra a "Esporte e Lazer". O "Brinquedo" é outro problema: a palavra é diferente, e só um UPDATE na tabela de passagem resolve.

sqlite> SELECT DISTINCT i.categoria FROM importados i LEFT JOIN categorias c ON c.nome = i.categoria COLLATE NOCASE WHERE c.id IS NULL;
┌───────────┐
│ categoria │
├───────────┤
│ Brinquedo │
└───────────┘
sqlite> UPDATE importados SET categoria = 'Brinquedos' WHERE categoria = 'Brinquedo';
sqlite> SELECT changes();
┌───────────┐
│ changes() │
├───────────┤
│ 1         │
└───────────┘

O changes() conta as linhas que o último comando mudou: 1, o Ioiô. Corrigir na tabela de passagem é seguro: o arquivo original continua intacto, e a tabela produtos nem foi tocada.

Missão: no editor, a Aurora completa está carregada, com a tabela categorias de verdade. A consulta abaixo acusa duas categorias sem par. Faça a comparação ignorar maiúsculas e minúsculas, para sobrar só o problema de verdade.

-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('  Borracha Branca ',          'Papelaria',       'Norte Suprimentos', '1,20',  '350'),
('Caderno Brochura, 48 folhas', 'Papelaria',       'Norte Suprimentos', '6,90',  '150'),
('Bola de Futebol',             'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca',                      'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem',            'Jardim',          'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('Ioiô Colorido',               'Brinquedo',       'Norte Suprimentos', '8,75',  '60');
-- a consulta
SELECT DISTINCT i.categoria
FROM importados i
LEFT JOIN categorias c ON c.nome = i.categoria
WHERE c.id IS NULL;
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('  Borracha Branca ',          'Papelaria',       'Norte Suprimentos', '1,20',  '350'),
('Caderno Brochura, 48 folhas', 'Papelaria',       'Norte Suprimentos', '6,90',  '150'),
('Bola de Futebol',             'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca',                      'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem',            'Jardim',          'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('Ioiô Colorido',               'Brinquedo',       'Norte Suprimentos', '8,75',  '60');
-- a consulta
SELECT DISTINCT i.categoria
FROM importados i
LEFT JOIN categorias c ON c.nome = i.categoria COLLATE NOCASE
WHERE c.id IS NULL;

Atenção: o NOCASE do SQLite só iguala as letras sem acento. Uma planilha com "ELETRÔNICOS" não encontraria "Eletrônicos" por causa do "Ô". Para esses casos, a saída é o UPDATE, como no Brinquedo.


Versão 5: para a tabela de verdade

Tudo conferido. Agora um único comando copia da tabela de passagem para produtos, limpando e convertendo no caminho: o INSERT INTO … SELECT (aula INSERT INTO SELECT). A Ana abre o banco de novo e, antes de tudo, liga as chaves estrangeiras, que no terminal vêm desligadas em cada conexão nova (aula PRAGMAs Úteis):

$ sqlite3 loja.db
SQLite version 3.50.4 2025-07-30 19:33:53
Enter ".help" for usage hints.
sqlite> PRAGMA foreign_keys = ON;
sqlite> INSERT INTO produtos (nome, categoria_id, fornecedor_id, preco, estoque)
   ...> SELECT DISTINCT TRIM(i.nome), c.id, f.id, CAST(REPLACE(i.preco, ',', '.') AS REAL), COALESCE(CAST(NULLIF(TRIM(i.estoque), '') AS INTEGER), 0)
   ...> FROM importados i
   ...> JOIN categorias c   ON c.nome = i.categoria COLLATE NOCASE
   ...> JOIN fornecedores f ON f.nome = i.fornecedor;
sqlite> SELECT changes();
7
sqlite> .mode box
sqlite> SELECT p.id, p.nome, c.nome AS categoria, p.preco, typeof(p.preco) AS tipo, p.estoque FROM produtos p JOIN categorias c ON c.id = p.categoria_id WHERE p.id > 45;
┌────┬─────────────────────────────┬─────────────────┬───────┬──────┬─────────┐
│ id │            nome             │    categoria    │ preco │ tipo │ estoque │
├────┼─────────────────────────────┼─────────────────┼───────┼──────┼─────────┤
│ 46 │ Caneta Gel Preta            │ Papelaria       │ 3.49  │ real │ 200     │
│ 47 │ Borracha Branca             │ Papelaria       │ 1.2   │ real │ 350     │
│ 48 │ Caderno Brochura, 48 folhas │ Papelaria       │ 6.9   │ real │ 150     │
│ 49 │ Bola de Futebol             │ Esporte e Lazer │ 59.9  │ real │ 0       │
│ 50 │ Peteca                      │ Esporte e Lazer │ 12.5  │ real │ 40      │
│ 51 │ Pá de Jardinagem            │ Jardim          │ 24.0  │ real │ 25      │
│ 52 │ Ioiô Colorido               │ Brinquedos      │ 8.75  │ real │ 60      │
└────┴─────────────────────────────┴─────────────────┴───────┴──────┴─────────┘
sqlite> SELECT COUNT(*) FROM produtos;
┌──────────┐
│ COUNT(*) │
├──────────┤
│ 52       │
└──────────┘
sqlite> DROP TABLE importados;
INSERT INTO produtos (…) SELECT …
Em vez de VALUES, as linhas vêm de uma consulta. As colunas do SELECT entram, na ordem, nas colunas listadas.
SELECT DISTINCT
As duas Canetas Gel são idênticas depois da limpeza, então entram uma vez só. 8 linhas na planilha, 7 produtos novos.
JOIN categorias c ON … COLLATE NOCASE
Troca o nome da categoria pelo id dela.
JOIN fornecedores f ON f.nome = i.fornecedor
O mesmo para o fornecedor: Norte Suprimentos é o id 6.
COALESCE(…, 0)
A decisão sobre o estoque vazio: a Bola de Futebol entra com 0, porque o fornecedor ainda não mandou nenhuma (aula IFNULL e COALESCE). A coluna estoque da Aurora é NOT NULL; sem o COALESCE, o NULL seria recusado.
o id
Não foi informado: o SQLite continuou a numeração, do 46 ao 52.
DROP TABLE importados
A tabela de passagem cumpriu o seu papel e vai embora.

Repare na coluna tipo: agora o preço é real de verdade. A Aurora passou de 45 para 52 produtos.

Missão: faça a importação inteira no editor. A caixa já tem a tabela de passagem e, no fim, a consulta de conferência. Falta o meio: corrija o "Brinquedo" com um UPDATE e escreva o INSERT INTO produtos … SELECT com a limpeza, o DISTINCT e os dois JOIN. Se a Aurora terminar com 52 produtos e o Ioiô estiver em Brinquedos, deu certo.

-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('  Borracha Branca ',          'Papelaria',       'Norte Suprimentos', '1,20',  '350'),
('Caderno Brochura, 48 folhas', 'Papelaria',       'Norte Suprimentos', '6,90',  '150'),
('Bola de Futebol',             'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca',                      'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem',            'Jardim',          'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('Ioiô Colorido',               'Brinquedo',       'Norte Suprimentos', '8,75',  '60');
-- 1. corrigir a categoria que não existe


-- 2. copiar para produtos, limpando no caminho


-- conferência
SELECT p.nome, c.nome AS categoria, p.preco, p.estoque
FROM produtos p
JOIN categorias c ON c.id = p.categoria_id
WHERE p.id > 45;
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('  Borracha Branca ',          'Papelaria',       'Norte Suprimentos', '1,20',  '350'),
('Caderno Brochura, 48 folhas', 'Papelaria',       'Norte Suprimentos', '6,90',  '150'),
('Bola de Futebol',             'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca',                      'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem',            'Jardim',          'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('Ioiô Colorido',               'Brinquedo',       'Norte Suprimentos', '8,75',  '60');
-- 1. corrigir a categoria que não existe
UPDATE importados SET categoria = 'Brinquedos' WHERE categoria = 'Brinquedo';

-- 2. copiar para produtos, limpando no caminho
INSERT INTO produtos (nome, categoria_id, fornecedor_id, preco, estoque)
SELECT DISTINCT TRIM(i.nome),
       c.id,
       f.id,
       CAST(REPLACE(i.preco, ',', '.') AS REAL),
       COALESCE(CAST(NULLIF(TRIM(i.estoque), '') AS INTEGER), 0)
FROM importados i
JOIN categorias c   ON c.nome = i.categoria COLLATE NOCASE
JOIN fornecedores f ON f.nome = i.fornecedor;

-- conferência
SELECT p.nome, c.nome AS categoria, p.preco, p.estoque
FROM produtos p
JOIN categorias c ON c.id = p.categoria_id
WHERE p.id > 45;

Esqueça o UPDATE e rode: o Ioiô simplesmente não entra, sem erro, porque o JOIN com categorias não acha par para "Brinquedo" e descarta a linha. Entram 6 produtos, e a Aurora fica com 51. Por isso a conferência da versão 4 vem antes: um JOIN que descarta linhas em silêncio é o jeito mais fácil de perder dados numa importação.


Versão 6: exportar de volta para CSV

A Marta quer conferir na planilha o que entrou. O caminho de volta usa três comandos de ponto antes do SELECT:

sqlite> .headers on
sqlite> .mode csv
sqlite> .once produtos-novos.csv
sqlite> SELECT p.id, p.nome, c.nome AS categoria, printf('%.2f', p.preco) AS preco, p.estoque FROM produtos p JOIN categorias c ON c.id = p.categoria_id WHERE p.id > 45;
sqlite> .quit
$ cat produtos-novos.csv
id,nome,categoria,preco,estoque
46,"Caneta Gel Preta",Papelaria,3.49,200
47,"Borracha Branca",Papelaria,1.20,350
48,"Caderno Brochura, 48 folhas",Papelaria,6.90,150
49,"Bola de Futebol","Esporte e Lazer",59.90,0
50,Peteca,"Esporte e Lazer",12.50,40
51,"Pá de Jardinagem",Jardim,24.00,25
52,"Ioiô Colorido",Brinquedos,8.75,60
.headers on
A primeira linha do arquivo sai com os nomes das colunas.
.mode csv
A saída passa a ser CSV: vírgulas entre as colunas e aspas onde precisa. O sqlite3 põe aspas em todo texto que tem espaço ou vírgula; "Peteca" e "Papelaria" saíram sem.
.once produtos-novos.csv
A saída do próximo comando vai para o arquivo, e não para a tela. Depois dele, tudo volta para a tela. (Para mandar vários comandos para um arquivo, existe o .output arquivo, que vale até um .output sem nome.)
printf('%.2f', p.preco)
Preço com 2 casas: 1.20, e não 1.2. Numa planilha de preços, fica mais legível.

Repare que a tela não mostrou nada depois do SELECT: a saída foi toda para o arquivo, e o cat confirma. Abra produtos-novos.csv numa planilha e as colunas se separam sozinhas.

E a planilha em português?

O LibreOffice e o Excel configurados para o Brasil às vezes esperam ponto e vírgula como separador, porque a vírgula é o separador decimal. O LibreOffice pergunta o separador ao abrir o arquivo. Se precisar, troque o separador depois do .mode csv:

sqlite> .mode csv
sqlite> .separator ";"
sqlite> .headers on
sqlite> SELECT id, nome, printf('%.2f', preco) AS preco FROM produtos WHERE id IN (48, 52);
id;nome;preco
48;"Caderno Brochura, 48 folhas";6.90
52;"Ioiô Colorido";8.75

Missão: o editor não grava arquivos, mas a consulta da exportação é SQL e pode ser preparada aqui. Depois da importação (já feita na caixa), escreva o SELECT da planilha de conferência: os 7 produtos novos com as colunas exatamente id, nome, categoria (o nome), preco (com 2 casas, via printf) e estoque.

-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('  Borracha Branca ',          'Papelaria',       'Norte Suprimentos', '1,20',  '350'),
('Caderno Brochura, 48 folhas', 'Papelaria',       'Norte Suprimentos', '6,90',  '150'),
('Bola de Futebol',             'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca',                      'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem',            'Jardim',          'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('Ioiô Colorido',               'Brinquedo',       'Norte Suprimentos', '8,75',  '60');
UPDATE importados SET categoria = 'Brinquedos' WHERE categoria = 'Brinquedo';
INSERT INTO produtos (nome, categoria_id, fornecedor_id, preco, estoque)
SELECT DISTINCT TRIM(i.nome), c.id, f.id,
       CAST(REPLACE(i.preco, ',', '.') AS REAL),
       COALESCE(CAST(NULLIF(TRIM(i.estoque), '') AS INTEGER), 0)
FROM importados i
JOIN categorias c   ON c.nome = i.categoria COLLATE NOCASE
JOIN fornecedores f ON f.nome = i.fornecedor;

-- a consulta da exportação
SELECT * FROM produtos WHERE id > 45;
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('  Borracha Branca ',          'Papelaria',       'Norte Suprimentos', '1,20',  '350'),
('Caderno Brochura, 48 folhas', 'Papelaria',       'Norte Suprimentos', '6,90',  '150'),
('Bola de Futebol',             'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca',                      'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem',            'Jardim',          'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('Ioiô Colorido',               'Brinquedo',       'Norte Suprimentos', '8,75',  '60');
UPDATE importados SET categoria = 'Brinquedos' WHERE categoria = 'Brinquedo';
INSERT INTO produtos (nome, categoria_id, fornecedor_id, preco, estoque)
SELECT DISTINCT TRIM(i.nome), c.id, f.id,
       CAST(REPLACE(i.preco, ',', '.') AS REAL),
       COALESCE(CAST(NULLIF(TRIM(i.estoque), '') AS INTEGER), 0)
FROM importados i
JOIN categorias c   ON c.nome = i.categoria COLLATE NOCASE
JOIN fornecedores f ON f.nome = i.fornecedor;

-- a consulta da exportação
SELECT p.id, p.nome, c.nome AS categoria,
       printf('%.2f', p.preco) AS preco, p.estoque
FROM produtos p
JOIN categorias c ON c.id = p.categoria_id
WHERE p.id > 45;

Formas diferentes de importar

A Ana importou para uma tabela nova, toda TEXT. Outra ideia comum é criar antes uma tabela com tipos e importar direto nela. Veja o que acontece:

sqlite> CREATE TABLE tipada (nome TEXT, categoria TEXT, fornecedor TEXT, preco REAL, estoque INTEGER);
sqlite> .import --csv --skip 1 novos-produtos.csv tipada
sqlite> .mode box
sqlite> .nullvalue NULL
sqlite> SELECT nome, preco, typeof(preco), estoque, typeof(estoque) FROM tipada WHERE rowid IN (1, 4);
┌──────────────────┬───────┬───────────────┬─────────┬─────────────────┐
│       nome       │ preco │ typeof(preco) │ estoque │ typeof(estoque) │
├──────────────────┼───────┼───────────────┼─────────┼─────────────────┤
│ Caneta Gel Preta │ 3,49  │ text          │ 200     │ integer         │
│ Bola de Futebol  │ 59,90 │ text          │         │ text            │
└──────────────────┴───────┴───────────────┴─────────┴─────────────────┘

O --skip 1 pula a linha do cabeçalho (a tabela já tem nomes de coluna). O estoque 200 virou integer: a coluna tipada converte o que parece número. Mas 3,49 não parece, e ficou texto numa coluna REAL; o estoque vazio ficou texto vazio numa coluna INTEGER. O SQLite guarda o que não consegue converter (aula Os Tipos no SQLite). Ou seja: tipos não limpam dados. A limpeza continua sendo com você.

FormaComandoQuando usar
Tabela nova, tudo texto.import --csv arquivo.csv importadosArquivo de fora, que você não conhece. Importa sem perder nada e limpa depois com SQL. A mais segura.
Tabela já criada, com tipos.import --csv --skip 1 arquivo.csv tabelaArquivo que você mesmo exportou, já limpo (ponto decimal, sem vazios).
Direto na tabela final.import --csv --skip 1 arquivo.csv produtosQuase nunca: o CSV precisaria ter exatamente as colunas da tabela, na ordem, e nenhum erro.
Pela planilhaa planilha gera INSERTs com fórmulasPouca coisa, uma vez só. Frágil com aspas e vírgulas.

Quando dá errado

Esqueceu o --csv
sqlite> .import novos-produtos.csv teste
novos-produtos.csv:4: unescaped " character
novos-produtos.csv:4: unescaped " character
novos-produtos.csv:4: unescaped " character
novos-produtos.csv:5: unescaped " character
novos-produtos.csv:5: unescaped " character
novos-produtos.csv:6: unescaped " character
novos-produtos.csv:6: unescaped " character
novos-produtos.csv:7: unescaped " character
novos-produtos.csv:7: unescaped " character
novos-produtos.csv:8: unescaped " character
novos-produtos.csv:8: unescaped " character
novos-produtos.csv:9: unescaped " character
novos-produtos.csv:9: unescaped " character
novos-produtos.csv:4: unterminated "-quoted field
sqlite> .schema teste
CREATE TABLE IF NOT EXISTS "teste"(
"nome,categoria,fornecedor,preco,estoque" TEXT);
sqlite> SELECT COUNT(*) FROM teste;
3

O que aconteceu: sem --csv, o .import usa o modo atual do sqlite3, que por padrão separa colunas com |, e não com vírgula. Ele não achou nenhum |, então a linha inteira virou uma coluna só, com o nome esquisito "nome,categoria,fornecedor,preco,estoque". As aspas no meio das linhas geraram uma chuva de avisos (unescaped " character: "caractere de aspas sem escape"; unterminated "-quoted field: "campo entre aspas que não termina"), e só 3 linhas entraram, embaralhadas.

Como consertar: DROP TABLE teste; e importe de novo com --csv. (Outra forma: .mode csv antes do .import faz o mesmo efeito.)

Errou o nome do arquivo
sqlite> .import --csv novos_produtos.csv importados
Error: cannot open "novos_produtos.csv"

O que aconteceu: "não consigo abrir": o arquivo se chama novos-produtos.csv, com hífen. O sqlite3 procura o arquivo na pasta onde você abriu o programa.

Como consertar: confira o nome com ls antes de abrir o sqlite3, ou use o caminho completo: .import --csv /home/ana/novos-produtos.csv importados.

O cabeçalho virou um produto
sqlite> CREATE TABLE sem_skip (nome TEXT, categoria TEXT, fornecedor TEXT, preco REAL, estoque INTEGER);
sqlite> .import --csv novos-produtos.csv sem_skip
sqlite> SELECT nome, preco FROM sem_skip WHERE rowid = 1;
┌──────┬───────┐
│ nome │ preco │
├──────┼───────┤
│ nome │ preco │
└──────┴───────┘

O que aconteceu: quando a tabela já existe, o .import trata a primeira linha do arquivo como dado. Agora existe um "produto" chamado nome, com preço preco.

Como consertar: importe para tabela existente sempre com --skip 1. Se já aconteceu: DELETE FROM sem_skip WHERE nome = 'nome';.

A vírgula decimal que virou número pela metade

Já visto na versão 2, mas é o erro mais caro de todos: '3,49' * 2 dá 6. Se a Ana tivesse inserido os preços sem o REPLACE, a coluna preco teria textos, e cada conta usaria só a parte antes da vírgula. Teste no editor:

SELECT '3,49' * 2                                AS sem_limpar,
       CAST(REPLACE('3,49', ',', '.') AS REAL) * 2 AS limpo,
       typeof('3,49')                            AS tipo;

Como consertar: REPLACE(preco, ',', '.') e CAST(… AS REAL), sempre, antes de levar para a tabela final. E confira o resultado com typeof.

O JOIN que engoliu uma linha
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('  Borracha Branca ',          'Papelaria',       'Norte Suprimentos', '1,20',  '350'),
('Caderno Brochura, 48 folhas', 'Papelaria',       'Norte Suprimentos', '6,90',  '150'),
('Bola de Futebol',             'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca',                      'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem',            'Jardim',          'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('Ioiô Colorido',               'Brinquedo',       'Norte Suprimentos', '8,75',  '60');
SELECT COUNT(*) AS linhas_na_planilha FROM importados;

SELECT COUNT(*) AS linhas_que_entrariam
FROM (SELECT DISTINCT TRIM(i.nome)
      FROM importados i
      JOIN categorias c ON c.nome = i.categoria COLLATE NOCASE);

O que aconteceu: 8 linhas na planilha, mas só 6 entrariam: uma é a Caneta repetida (essa é de propósito), e a outra é o Ioiô, cuja categoria "Brinquedo" não existe. O JOIN descarta, sem erro, a linha que não acha par.

Como consertar: antes de todo INSERT … SELECT com JOIN, conte: as linhas que vão entrar batem com as que você esperava? Se não, procure as órfãs com LEFT JOIN … IS NULL (versão 4).

Nos outros bancos

O .import é do programa sqlite3; cada banco tem o seu jeito de ler CSV, e a estratégia da tabela de passagem vale para todos. Trechos ilustrativos, que não rodam no editor:

-- PostgreSQL (no psql, o arquivo é lido do seu computador)
\copy importados FROM 'novos-produtos.csv' WITH (FORMAT csv, HEADER true)

-- MySQL / MariaDB
LOAD DATA LOCAL INFILE 'novos-produtos.csv' INTO TABLE importados
  FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'
  IGNORE 1 LINES;

-- SQL Server
BULK INSERT importados FROM 'C:\dados\novos-produtos.csv'
  WITH (FORMAT = 'CSV', FIRSTROW = 2);

Nos três, a tabela de destino precisa existir antes. Mais em As Diferenças entre os Bancos.


Referência rápida

PassoComando
Importar para tabela nova (cabeçalho vira nome de coluna).import --csv arquivo.csv importados
Importar para tabela existente.import --csv --skip 1 arquivo.csv tabela
Ver o que o .import criou.schema importados
Ver o tipo guardadoSELECT typeof(coluna) …
Ver espaços escondidos'[' || coluna || ']'
Tirar espaçosTRIM(coluna)
Vírgula decimal → númeroCAST(REPLACE(preco, ',', '.') AS REAL)
Vazio → NULLNULLIF(TRIM(coluna), '')
Achar repetidosGROUP BY TRIM(nome) HAVING COUNT(*) > 1
Achar nomes sem parLEFT JOIN … ON … COLLATE NOCASE WHERE c.id IS NULL
Copiar limpandoINSERT INTO produtos (…) SELECT DISTINCT …
Exportar.headers on + .mode csv + .once arquivo.csv + SELECT …
Separador ponto e vírgula.separator ";" depois do .mode csv

Exercícios

1. Importar de verdade

Complete para importar o arquivo como CSV:

.import novos-produtos.csv importados

2. Vazio não é zero

Complete para transformar o texto vazio em NULL:

(TRIM(estoque), '')

3. Depois de .import --csv novos-produtos.csv importados (tabela nova), qual é o tipo das colunas?

4. Quanto dá SELECT '3,49' * 2; no SQLite?

5. A planilha tinha 8 linhas, mas o INSERT … SELECT com JOIN categorias inseriu 6. O que é mais provável?

6. Para que serve o .once relatorio.csv?

Desafio: o relatório de preços da planilha

Missão: antes de importar, a Marta quer saber quantos produtos diferentes a planilha traz e quanto custaria comprar uma unidade de cada. Direto na tabela de passagem, mostre uma linha com produtos (a contagem dos nomes distintos, já sem espaços) e total (a soma dos preços convertidos, de cada produto uma vez, arredondada em 2 casas).

-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('  Borracha Branca ',          'Papelaria',       'Norte Suprimentos', '1,20',  '350'),
('Caderno Brochura, 48 folhas', 'Papelaria',       'Norte Suprimentos', '6,90',  '150'),
('Bola de Futebol',             'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca',                      'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem',            'Jardim',          'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('Ioiô Colorido',               'Brinquedo',       'Norte Suprimentos', '8,75',  '60');
-- a consulta
SELECT COUNT(*) AS produtos, SUM(preco) AS total
FROM importados;
-- a tabela de passagem, igual à que o .import criou
CREATE TABLE importados (nome TEXT, categoria TEXT, fornecedor TEXT, preco TEXT, estoque TEXT);
INSERT INTO importados VALUES
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('  Borracha Branca ',          'Papelaria',       'Norte Suprimentos', '1,20',  '350'),
('Caderno Brochura, 48 folhas', 'Papelaria',       'Norte Suprimentos', '6,90',  '150'),
('Bola de Futebol',             'Esporte e Lazer', 'Norte Suprimentos', '59,90', ''),
('Peteca',                      'esporte e lazer', 'Norte Suprimentos', '12,50', '40'),
('Pá de Jardinagem',            'Jardim',          'Norte Suprimentos', '24,00', '25'),
('Caneta Gel Preta',            'Papelaria',       'Norte Suprimentos', '3,49',  '200'),
('Ioiô Colorido',               'Brinquedo',       'Norte Suprimentos', '8,75',  '60');
-- a consulta
WITH limpos AS (
    SELECT DISTINCT TRIM(nome) AS nome,
           CAST(REPLACE(preco, ',', '.') AS REAL) AS preco
    FROM importados
)
SELECT COUNT(*) AS produtos, ROUND(SUM(preco), 2) AS total
FROM limpos;

Rode o código inicial antes de resolver: ele diz 8 produtos e um total de 116.0, porque conta a Caneta duas vezes e soma só a parte inteira de cada preço (3 + 1 + 6 + 59…). São 7 produtos e 116.74. A diferença parece pequena aqui; numa planilha de mil linhas, não seria.


Resumo

  • CSV é uma planilha em texto: vírgula separa colunas, aspas protegem as vírgulas de dentro, a primeira linha costuma ser o cabeçalho.
  • Importe com .import --csv para uma tabela de passagem. Numa tabela nova, o cabeçalho vira nome de coluna e tudo vira TEXT; numa tabela existente, use --skip 1.
  • Limpe com SQL: TRIM para espaços, REPLACE(…, ',', '.') + CAST(… AS REAL) para a vírgula decimal, NULLIF(…, '') para células vazias, DISTINCT para linhas repetidas.
  • Confira antes de inserir: typeof para os tipos, LEFT JOIN … IS NULL para nomes sem par, COUNT para as linhas que vão entrar. Um JOIN descarta órfãs em silêncio.
  • Leve para a tabela final com INSERT INTO … SELECT, com PRAGMA foreign_keys = ON no terminal, e apague a tabela de passagem.
  • Exporte com .headers on, .mode csv e .once arquivo.csv; para planilhas em português, .separator ";".