1. Por que encoding/csv y no strings.Split
CSV parece tan simple que el cerebro de una persona principiante (e incluso, a veces, el de una persona con experiencia un viernes por la noche) propone: «si al fin y al cabo son solo lineas separadas por \n, y los campos van separados por ,, ahora lo hago rapido con strings.Split». Es una idea excelente... hasta que aparece una coma dentro de comillas, un salto de linea dentro de un campo o una comilla " en el propio valor. CSV es un formato con reglas, no “texto con comas”.
En Go, el camino correcto casi siempre es uno: el paquete encoding/csv. Ya conoce las reglas de CSV, sabe manejar correctamente las comillas y el escape, y, lo mas importante, funciona encima de io.Reader/io.Writer; por tanto, lee igual de bien desde un archivo, una cadena, la red o un buffer (e incluso desde «un flujo que aun no has inventado»).
Para no hablar en abstracto, aqui va un mini contraejemplo. Compila, parece logico, pero se rompe con un CSV real:
package main
import (
"fmt"
"strings"
)
func main() {
line := `1,"Buy milk, please",false`
parts := strings.Split(line, ",")
fmt.Println(parts) // [1 "Buy milk please" false] (se desvio)
}
Si, ademas, las comillas se “pegaran” al resultado y el campo se partiria en dos. En resumen: parseamos CSV con un parser de CSV, no porque “este de moda”, sino porque queremos dormir tranquilos.
2. csv.Reader: lectura de CSV desde io.Reader
Cuando ves por primera vez csv.NewReader(r), surge la sensacion de: «¿y por que no csv.ReadFile("x.csv")?». Y aqui Go es bastante coherente: casi todo el I/O se construye alrededor de flujos. Eso da flexibilidad: el mismo codigo funciona con archivos, cadenas, respuestas HTTP y buffers de prueba. Es como si escribieras una “funcion aspiradora”: le da igual de donde venga el polvo, lo importante es que haya un tubo.
csv.Reader lee CSV como una secuencia de registros (records), donde cada registro es un []string (campos). Los metodos principales:
- Read() — leer un registro.
- ReadAll() — leer todos los registros de una vez.
El ejemplo mas corto: ReadAll()
ReadAll() es util cuando el CSV es pequeno y sabes que no tendras problemas de memoria. Para tareas de estudio y para “export/import de tareas de 200 lineas” esta bien. Para un CSV de 4 GB, ya no (y tu portatil empezara a sospechar que lo traicionas).
package main
import (
"encoding/csv"
"fmt"
"strings"
)
func main() {
data := "id,title,done\n1,Buy milk,false\n2,Learn Go,true\n"
r := csv.NewReader(strings.NewReader(data))
records, err := r.ReadAll()
if err != nil {
fmt.Println("read all:", err)
return
}
fmt.Println(records) // [[id title done] [1 Buy milk false] [2 Learn Go true]]
}
Observa: el resultado es [][]string. Es decir, una “tabla de cadenas”. A partir de ahi, tu tarea es convertir las cadenas en tus tipos (por ejemplo, int y bool). Pero la conversion y la validacion son otra logica; hoy mantenemos el foco precisamente en Reader/Writer.
Modo en flujo: Read() + bucle hasta io.EOF
La lectura en flujo es cuando lees “un registro por vez” dentro de un bucle. Es un poco mas verbosa, pero consume menos memoria y encaja bien con archivos grandes.
Punto clave: io.EOF es un final normal de lectura, no un “error de formato”. Cuando llega EOF, terminamos el bucle.
package main
import (
"encoding/csv"
"fmt"
"io"
"strings"
)
func main() {
data := "a,b\nc,d\n"
r := csv.NewReader(strings.NewReader(data))
for {
rec, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
fmt.Println("read:", err)
return
}
fmt.Println("record:", rec) // record: [a b] luego record: [c d]
}
}
Este patron de “Read() → EOF → break” lo veras muchas veces despues en Go. Es simple y predecible, como un taburete.
Ajustes de csv.Reader: Comma, TrimLeadingSpace, FieldsPerRecord
El CSV real a menudo difiere del “ideal” de los ejemplos. Por eso csv.Reader tiene ajustes. Su sentido es mas importante que memorizar los nombres.
Separador: Comma
Si, el campo se llama Comma, aunque uses ;. La logica es sencilla: historicamente, CSV significa “comma-separated values”, y luego la gente dijo “vamos a usar la coma como separador decimal en algunos paises” — y se lio todo.
package main
import (
"encoding/csv"
"fmt"
"strings"
)
func main() {
data := "id;title;done\n1;Buy milk;false\n"
r := csv.NewReader(strings.NewReader(data))
r.Comma = ';'
rec, _ := r.Read()
fmt.Println(rec) // [id title done]
}
Espacios: TrimLeadingSpace
A veces los datos tienen este aspecto: 1, Buy milk, false. Y el espacio despues de la coma no forma parte del valor; simplemente alguien “lo queria bonito”. TrimLeadingSpace = true puede ayudar (pero esta es una politica: a veces el espacio si importa).
Cantidad de campos: FieldsPerRecord
Una cosa muy util: cuando esperas que cada linea tenga, por ejemplo, 3 campos, puedes hacer que el parser se queje si de repente llegan 2 o 4. Es una senal temprana de que el CSV se ha desviado.
La idea es esta: despues de leer la cabecera (header), puedes establecer FieldsPerRecord = len(header); asi cualquier linea torcida aparecera enseguida, y no en lo mas profundo de tu codigo.
3. csv.Writer: escritura de CSV en io.Writer
Escribir CSV “uniendo cadenas a mano” es algo asi como reparar un reloj con un martillo: a veces marca la hora, pero mejor no mirarlo demasiado. CSV tiene reglas de comillas y de escape. csv.Writer lo hace por ti, y ese es el sentido principal de su existencia.
Escritura minima de CSV en un buffer
En este ejemplo escribimos CSV en bytes.Buffer y luego imprimimos el resultado. Es util para pruebas y para entender que sale realmente “afuera”.
package main
import (
"bytes"
"encoding/csv"
"fmt"
)
func main() {
var buf bytes.Buffer
w := csv.NewWriter(&buf)
_ = w.Write([]string{"id", "title", "done"})
_ = w.Write([]string{"1", "Buy milk", "false"})
w.Flush()
if err := w.Error(); err != nil {
fmt.Println("csv error:", err)
return
}
fmt.Print(buf.String())
// id,title,done
// 1,Buy milk,false
}
Aqui hay una regla importante, y conviene grabarla mentalmente: despues de escribir, siempre hacemos Flush() y comprobamos w.Error().
¿Por que? Porque la escritura puede estar bufferizada y el error puede aparecer no de inmediato. Es un patron tipico de los writers con buffer en Go: “escribimos muchas veces → comprobamos una vez al final”. Una idea parecida la veras en otros lugares de la libreria estandar.
CSV escapa solo las comillas y las comas
Probemos con un campo que contiene comillas y comas. Montarlo a mano es garantia de equivocarse al menos una vez en la vida. En cambio, csv.Writer lo resuelve sin problemas.
package main
import (
"bytes"
"encoding/csv"
"fmt"
)
func main() {
var buf bytes.Buffer
w := csv.NewWriter(&buf)
_ = w.Write([]string{"note"})
_ = w.Write([]string{`He said "hi", then left`})
w.Flush()
_ = w.Error()
fmt.Print(buf.String())
// note
// "He said ""hi"", then left"
}
Observa: la comilla " dentro del valor se convierte en "" — ese es el escape estandar de CSV. Y el campo entero se encierra entre comillas porque dentro hay tanto " como ,.
Ajustes de csv.Writer: Comma y UseCRLF
El writer tambien tiene ajustes.
Comma — el mismo sentido: que separador usar al escribir.
UseCRLF — escribir lineas con \r\n en lugar de \n. A veces hace falta para compatibilidad con algunos sistemas que esperan un salto “de Windows”. En la mayoria de los pipelines modernos basta con \n, pero conviene saber que este mando existe.
Sobre Flush() y por que no es una “cosmetica opcional”
Una idea muy frecuente entre principiantes es: “Bueno, si ya hice Write, entonces ya se guardo”. En realidad, muchos writers bufferizan los datos: los acumulan en memoria para escribir por tandas. Eso es mas rapido y mas comodo para el sistema. El precio es la necesidad de finalizar.
csv.Writer bufferiza la salida, asi que el protocolo correcto es este: “hacemos muchos Write → llamamos a Flush → comprobamos el error”. Este estilo se parece a como estan implementados los writers bufferizados en la libreria estandar: el error puede aparecer al vaciar el buffer al final.
Si olvidas Flush(), puedes obtener un archivo en el que “parece que falta algo”, y ese es uno de los bugs mas irritantes: el programa aparentemente “termino bien”, pero los datos desaparecieron.
4. Importacion y exportacion de tareas mediante CSV
Ahora juntaremos todo en un pequeno fragmento de codigo. Imaginemos que ya tenemos una aplicacion sencilla de tareas (un todo), y queremos exportar tareas a CSV e importarlas de vuelta.
Aqui no hacemos una “importacion industrial perfecta” con informes y montones de comprobaciones; eso es otra historia. Ahora el objetivo es mas simple: aprender a conectar encoding/csv como transporte, y no como rompecabezas.
Para empezar, definamos el modelo de tarea:
package main
type Task struct {
ID int
Title string
Done bool
}
Exportacion: WriteTasksCSV(w io.Writer, tasks []Task) error
El sentido de la funcion: no sabe donde escribe (en un archivo, en la red, en un buffer); se le pasa un io.Writer. Eso es muy de Go.
package main
import (
"encoding/csv"
"fmt"
"io"
"strconv"
)
func WriteTasksCSV(out io.Writer, tasks []Task) error {
w := csv.NewWriter(out)
if err := w.Write([]string{"id", "title", "done"}); err != nil {
return fmt.Errorf("write header: %w", err)
}
for _, t := range tasks {
rec := []string{
strconv.Itoa(t.ID),
t.Title,
strconv.FormatBool(t.Done),
}
if err := w.Write(rec); err != nil {
return fmt.Errorf("write task id=%d: %w", t.ID, err)
}
}
w.Flush()
if err := w.Error(); err != nil {
return fmt.Errorf("flush csv: %w", err)
}
return nil
}
Aqui envolvemos los errores con %w para no perder la causa y, al mismo tiempo, anadir contexto de “donde exactamente fallo”.
Y de nuevo vemos el rito importante: Flush() → w.Error().
Importacion: ReadTasksCSV(r io.Reader) ([]Task, error)
Ahora leemos CSV y obtenemos []Task. Haremos una version que espera la cabecera id,title,done y luego lee las lineas.
package main
import (
"encoding/csv"
"fmt"
"io"
"strconv"
)
func ReadTasksCSV(in io.Reader) ([]Task, error) {
r := csv.NewReader(in)
header, err := r.Read()
if err != nil {
return nil, fmt.Errorf("read header: %w", err)
}
_ = header // por ahora no validamos la cabecera en profundidad
var tasks []Task
for {
rec, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("read record: %w", err)
}
id, err := strconv.Atoi(rec[0])
if err != nil {
return nil, fmt.Errorf("parse id %q: %w", rec[0], err)
}
done, err := strconv.ParseBool(rec[2])
if err != nil {
return nil, fmt.Errorf("parse done %q: %w", rec[2], err)
}
tasks = append(tasks, Task{ID: id, Title: rec[1], Done: done})
}
return tasks, nil
}
Si, aqui hay una vulnerabilidad: no comprobamos la longitud de rec, y con una linea defectuosa obtendremos una panic por indice. En esta leccion lo dejo como “esqueleto ilustrativo”, no como “importacion industrial que sobreviva al apocalipsis”. Pero incluso asi se ve lo principal: el parseo CSV lo hace csv.Reader, y la conversion de tipos y las comprobaciones basicas las hacemos nosotros.
Ejemplo rapido “conectado”: escribimos → leemos de nuevo
Para sentir que todo funciona de verdad, podemos pasarlo en memoria mediante bytes.Buffer.
package main
import (
"bytes"
"fmt"
)
func main() {
tasks := []Task{
{ID: 1, Title: `Buy milk, please`, Done: false},
{ID: 2, Title: `Learn "Go"`, Done: true},
}
var buf bytes.Buffer
_ = WriteTasksCSV(&buf, tasks)
loaded, _ := ReadTasksCSV(&buf)
fmt.Println(loaded)
// [{1 Buy milk, please false} {2 Learn "Go" true}]
}
Aqui resulta especialmente agradable que las comillas y las comas dentro de Title no rompen el formato, porque se ocupan de ello csv.Writer y csv.Reader, no tu magia manual de cadenas.
Flujo de datos: Reader/Writer y CSV
A veces conviene detenerse y ver el panorama general. En Go, a menudo se diseña el codigo para que el formato (CSV) sea solo un “acople” sobre el flujo de lectura/escritura. Cambias la fuente/destino, pero no reescribes la logica de negocio.
flowchart TD
A["Origen de datos<br/>file / string / network"] --> B["io.Reader"]
B --> C["csv.NewReader"]
C --> D["[]string records"]
D --> E["parse -> Task"]
E --> F["[]Task"]
F --> G["format -> []string"]
G --> H["csv.NewWriter"]
H --> I["io.Writer"]
I --> J["Destino<br/>file / buffer / stdout"]
Si lo lees como una historia: “tenemos io.Reader, encima ponemos csv.Reader, obtenemos registros y los convertimos en Task”. Y al exportar vamos por el camino inverso.
5. Errores tipicos al trabajar con encoding/csv
Error n.º 1: parsear CSV con strings.Split o con Scanner por lineas y “partir por comas”.
Ese codigo primero parece heroico; luego encuentra comillas o comas dentro de los campos, y el heroismo se convierte en arqueologia de bugs. CSV debe leerse con csv.Reader y escribirse con csv.Writer, porque ellos implementan las reglas del formato, no “algo parecido”.
Error n.º 2: olvidar Flush() y no comprobar w.Error().
Es un clasico: llamas honestamente a Write, y el archivo queda vacio o recortado. La razon es simple: parte de los datos estaba en el buffer y no se vacio, o el error de escritura solo aparecio al finalizar. El habito “Flush() + Error() siempre” hace que la exportacion sea mucho mas fiable.
Error n.º 3: no tratar io.EOF por separado y considerarlo un “error de lectura”.
En la lectura en flujo, io.EOF es una senal normal de que los datos terminaron. Si no lo separas en una rama propia y no haces break, o bien imprimiras “error: EOF”, o bien romperas el bucle.
Error n.º 4: ir directamente a rec[0], rec[1], rec[2] sin comprobar la longitud del registro.
Con datos de estudio esto se perdona, pero en una importacion real cualquier linea “torcida” puede provocar una panic por salida de rango. Aunque aun no construyas una validacion compleja, una comprobacion minima de “cuantos campos llegaron” ahorra horas de depuracion.
Error n.º 5: perder el contexto de los errores y devolver solo err.
Cuando la lectura falla en el registro 736, el mensaje “invalid syntax” no inspira demasiado. Es mejor envolver los errores con fmt.Errorf("...: %w", err) para que quede claro en que paso y con que valor esta el problema. Ese estilo no hace el codigo “mas bonito por ser bonito”: hace posible el mantenimiento.
GO TO FULL VERSION