Crear una herramienta en Go para convertir CSV a JSON

Tutorial sencillo para leer CSV, validar datos, convertir a JSON y generar una CLI básica en Go. Proyecto ideal para empezar.

Cover for Crear una herramienta en Go para convertir CSV a JSON

Este es probablemente el proyecto útil más simple que puedes construir en Go. Sin frameworks, sin bases de datos, sin HTTP. Solo ficheros, structs y la librería estándar. Lees un CSV, lo conviertes a JSON y lo sacas por stdout o lo escribes a un fichero. Nada más. Y en el proceso tocas lectura de ficheros, parsing, validación, serialización, flags de línea de comandos y gestión de errores. Todo lo que necesitas para sentirte cómodo con el lenguaje antes de meterte en cosas más complejas.

Si estás buscando proyectos para aprender Go, este es un punto de partida excelente. Es lo bastante pequeño como para terminarlo en una tarde y lo bastante real como para que el resultado sea algo que puedes usar de verdad.


Qué vamos a construir

Una herramienta de línea de comandos que:

  1. Recibe un fichero CSV como argumento.
  2. Lee y parsea su contenido.
  3. Valida los datos: detecta campos vacíos, tipos incorrectos, filas mal formadas.
  4. Convierte las filas a una estructura Go tipada.
  5. Serializa esa estructura a JSON.
  6. Soporta salida compacta o con formato legible (pretty print).
  7. Permite especificar un fichero de salida o imprimir por stdout.

El resultado final se usa así:

csvtojson -input datos.csv -output resultado.json -pretty

O en modo compacto directo a stdout:

csvtojson -input datos.csv

Sin dependencias externas. Todo con la librería estándar de Go.


Setup del proyecto

Crea el directorio del proyecto e inicializa el módulo:

mkdir csvtojson && cd csvtojson
go mod init csvtojson

Crea un fichero main.go. Esa será toda la estructura por ahora. Un solo fichero, un solo paquete. Cuando el proyecto sea más grande puedes separarlo, pero para una herramienta como esta no tiene sentido complicarlo desde el principio.

Si no tienes experiencia con el comando go, lo básico que necesitas saber es que go mod init crea el fichero go.mod que define el módulo, y go run main.go compila y ejecuta directamente sin generar un binario.

Para el CSV de ejemplo, crea un fichero datos.csv con este contenido:

nombre,edad,email,ciudad
Ana García,34,ana@example.com,Madrid
Pedro López,28,pedro@example.com,Barcelona
María Torres,,maria@example.com,Valencia
,45,sin-email,Sevilla
Carlos Ruiz,abc,carlos@example.com,Bilbao

He incluido datos sucios a propósito: una edad vacía, un nombre vacío y una edad que no es un número. Esto nos servirá para la parte de validación.


Leer CSV con encoding/csv

Go tiene el paquete encoding/csv en su librería estándar. No necesitas instalar nada. El paquete expone un csv.Reader que toma un io.Reader y devuelve registros como slices de strings.

package main

import (
	"encoding/csv"
	"fmt"
	"os"
)

func main() {
	file, err := os.Open("datos.csv")
	if err != nil {
		fmt.Fprintf(os.Stderr, "error al abrir el fichero: %v\n", err)
		os.Exit(1)
	}
	defer file.Close()

	reader := csv.NewReader(file)
	records, err := reader.ReadAll()
	if err != nil {
		fmt.Fprintf(os.Stderr, "error al leer CSV: %v\n", err)
		os.Exit(1)
	}

	for i, record := range records {
		fmt.Printf("Fila %d: %v\n", i, record)
	}
}

reader.ReadAll() carga todo el fichero en memoria. Para ficheros pequeños o medianos (hasta cientos de miles de filas) es lo más simple y no hay problema de rendimiento. Si necesitas procesar ficheros enormes, puedes usar reader.Read() en un bucle para leer fila a fila, pero para esta herramienta no hace falta.

El primer registro (records[0]) contiene las cabeceras. El resto son datos. Esa distinción es importante porque vamos a usar las cabeceras para mapear cada campo a su posición.

Un detalle del csv.Reader: por defecto asume que el delimitador es la coma. Si necesitas punto y coma u otro carácter, puedes cambiarlo con reader.Comma = ';' antes de llamar a ReadAll().


Mapear filas a structs

Trabajar con slices de strings está bien para leer, pero para serializar a JSON necesitamos una estructura tipada. Vamos a definir un struct que represente cada fila del CSV y una función que mapee los registros.

type Person struct {
	Nombre string `json:"nombre"`
	Edad   int    `json:"edad"`
	Email  string `json:"email"`
	Ciudad string `json:"ciudad"`
}

Los tags json:"..." controlan cómo se serializa cada campo a JSON. Sin ellos, Go usaría el nombre del campo con la primera letra en mayúscula, que no es lo que queremos.

Ahora la función de mapeo:

import "strconv"

func mapRecordToPerson(header []string, record []string) (Person, error) {
	if len(record) != len(header) {
		return Person{}, fmt.Errorf("la fila tiene %d campos, se esperaban %d", len(record), len(header))
	}

	fieldMap := make(map[string]string)
	for i, h := range header {
		fieldMap[h] = record[i]
	}

	edad, err := strconv.Atoi(fieldMap["edad"])
	if err != nil {
		return Person{}, fmt.Errorf("campo 'edad' no es un número válido: %q", fieldMap["edad"])
	}

	return Person{
		Nombre: fieldMap["nombre"],
		Edad:   edad,
		Email:  fieldMap["email"],
		Ciudad: fieldMap["ciudad"],
	}, nil
}

Usamos un mapa intermedio (fieldMap) para no depender del orden de las columnas. Esto hace que la herramienta funcione aunque alguien cambie el orden de las columnas en el CSV, siempre que los nombres de las cabeceras sean los mismos.

strconv.Atoi convierte un string a int. Si el valor no es un número, devuelve un error. Go te obliga a gestionarlo explícitamente. No hay excepciones, no hay conversiones implícitas. Cada error se comprueba donde ocurre.


Validar datos: campos vacíos, tipos incorrectos

La función de mapeo ya detecta tipos incorrectos en la edad. Pero necesitamos una validación más completa. Campos vacíos, emails que no tienen sentido, filas con datos incompletos. Vamos a añadir una función de validación que devuelva errores descriptivos.

import "strings"

type ValidationError struct {
	Row     int
	Field   string
	Message string
}

func (e ValidationError) Error() string {
	return fmt.Sprintf("fila %d, campo '%s': %s", e.Row, e.Field, e.Message)
}

func validatePerson(row int, header []string, record []string) []ValidationError {
	var errs []ValidationError

	if len(record) != len(header) {
		errs = append(errs, ValidationError{
			Row:     row,
			Field:   "-",
			Message: fmt.Sprintf("número de campos incorrecto: tiene %d, se esperaban %d", len(record), len(header)),
		})
		return errs
	}

	fieldMap := make(map[string]string)
	for i, h := range header {
		fieldMap[h] = strings.TrimSpace(record[i])
	}

	if fieldMap["nombre"] == "" {
		errs = append(errs, ValidationError{Row: row, Field: "nombre", Message: "campo vacío"})
	}

	if fieldMap["edad"] == "" {
		errs = append(errs, ValidationError{Row: row, Field: "edad", Message: "campo vacío"})
	} else if _, err := strconv.Atoi(fieldMap["edad"]); err != nil {
		errs = append(errs, ValidationError{Row: row, Field: "edad", Message: fmt.Sprintf("no es un número válido: %q", fieldMap["edad"])})
	}

	if fieldMap["email"] == "" {
		errs = append(errs, ValidationError{Row: row, Field: "email", Message: "campo vacío"})
	} else if !strings.Contains(fieldMap["email"], "@") {
		errs = append(errs, ValidationError{Row: row, Field: "email", Message: "formato de email inválido"})
	}

	if fieldMap["ciudad"] == "" {
		errs = append(errs, ValidationError{Row: row, Field: "ciudad", Message: "campo vacío"})
	}

	return errs
}

Definimos un tipo ValidationError que implementa la interfaz error. Esto es idiomático en Go: en lugar de lanzar excepciones, devuelves valores que describen el problema. El código que llama decide qué hacer con ellos. Puede abortar la ejecución, saltar la fila o acumular los errores y mostrar un resumen.

La validación del email es básica: solo comprobamos que contenga @. Para una herramienta real podrías usar net/mail.ParseAddress, pero para nuestro caso es suficiente.


Convertir a JSON con encoding/json

Con los datos validados y mapeados a structs, la conversión a JSON es trivial. El paquete encoding/json de la librería estándar hace todo el trabajo.

import "encoding/json"

func toJSON(people []Person, pretty bool) ([]byte, error) {
	if pretty {
		return json.MarshalIndent(people, "", "  ")
	}
	return json.Marshal(people)
}

json.Marshal serializa cualquier struct con tags JSON a un []byte. json.MarshalIndent hace lo mismo pero con indentación legible. Los dos primeros argumentos extra son el prefijo (normalmente vacío) y la cadena de indentación.

El resultado con pretty = true es algo así:

[
  {
    "nombre": "Ana García",
    "edad": 34,
    "email": "ana@example.com",
    "ciudad": "Madrid"
  },
  {
    "nombre": "Pedro López",
    "edad": 28,
    "email": "pedro@example.com",
    "ciudad": "Barcelona"
  }
]

Y con pretty = false:

[{"nombre":"Ana García","edad":34,"email":"ana@example.com","ciudad":"Madrid"},{"nombre":"Pedro López","edad":28,"email":"pedro@example.com","ciudad":"Barcelona"}]

La versión compacta es mejor para pipelines y procesamiento automatizado. La versión con formato es mejor para depurar o para humanos.


Pretty printing vs salida compacta

La diferencia entre ambos modos no es solo estética. En producción, cuando conectas la salida de una herramienta con otra mediante pipes, la salida compacta es lo que necesitas. Cada byte cuenta si estás procesando millones de registros.

Pero durante el desarrollo, o cuando quieres inspeccionar el resultado manualmente, el pretty print ahorra tiempo. No necesitas pasar el JSON por jq o pegarlo en un formatter online.

Por eso nuestra herramienta soporta ambos modos. El flag -pretty activa la indentación. Sin él, la salida es compacta por defecto. Es una convención habitual en herramientas CLI: el modo silencioso y eficiente es el default, el modo humano se activa explícitamente.

Un truco útil: si tu herramienta solo escribe a stdout, puedes combinarla con jq para formatear sobre la marcha:

csvtojson -input datos.csv | jq .

Pero tener el flag integrado es más cómodo y elimina la dependencia de jq.


Añadir flags CLI: fichero de entrada, de salida y opciones de formato

Go tiene el paquete flag en su librería estándar. No necesitas cobra, urfave/cli ni ninguna otra dependencia para una herramienta simple. Si más adelante necesitas subcomandos o autocompletado, puedes mirar cómo crear una CLI en Go con herramientas más avanzadas. Pero para esto, flag es perfecto.

import "flag"

func main() {
	inputFile := flag.String("input", "", "fichero CSV de entrada (obligatorio)")
	outputFile := flag.String("output", "", "fichero JSON de salida (stdout si no se especifica)")
	pretty := flag.Bool("pretty", false, "formato JSON con indentación")
	strict := flag.Bool("strict", false, "abortar si hay errores de validación")
	flag.Parse()

	if *inputFile == "" {
		fmt.Fprintln(os.Stderr, "error: debes especificar un fichero de entrada con -input")
		flag.Usage()
		os.Exit(1)
	}
}

flag.String y flag.Bool devuelven punteros. Hay que desreferenciarlos con * para obtener el valor. Es una de las cosas que al principio se siente raro en Go, pero tiene su lógica: flag.Parse() rellena los valores después de definirlos, así que necesita una referencia mutable.

El flag -strict es útil para diferentes escenarios. En modo normal, la herramienta salta las filas con errores y muestra warnings. En modo estricto, cualquier error de validación detiene la ejecución. Esto es importante cuando usas la herramienta dentro de un pipeline de datos donde no quieres datos parciales.


Gestión de errores: mensajes claros para el usuario

Go no tiene excepciones. Cada función que puede fallar devuelve un error como último valor de retorno. Esto significa que el código de gestión de errores está siempre visible, siempre explícito. A cambio, obtienes control total sobre qué hacer en cada caso.

Para una herramienta CLI, los mensajes de error deben ser útiles. Nada de “error: something went wrong”. El usuario necesita saber qué fichero falló, en qué fila está el problema y qué campo tiene datos incorrectos.

func processCSV(inputPath string, strict bool) ([]Person, error) {
	file, err := os.Open(inputPath)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, fmt.Errorf("el fichero '%s' no existe", inputPath)
		}
		if os.IsPermission(err) {
			return nil, fmt.Errorf("sin permisos para leer '%s'", inputPath)
		}
		return nil, fmt.Errorf("no se puede abrir '%s': %w", inputPath, err)
	}
	defer file.Close()

	reader := csv.NewReader(file)
	records, err := reader.ReadAll()
	if err != nil {
		return nil, fmt.Errorf("error al parsear CSV: %w", err)
	}

	if len(records) < 2 {
		return nil, fmt.Errorf("el fichero CSV está vacío o solo tiene cabeceras")
	}

	header := records[0]
	var people []Person
	var allErrors []ValidationError

	for i, record := range records[1:] {
		rowNum := i + 2 // +2 porque empezamos en 1 y saltamos la cabecera

		validationErrs := validatePerson(rowNum, header, record)
		if len(validationErrs) > 0 {
			allErrors = append(allErrors, validationErrs...)
			if strict {
				return nil, fmt.Errorf("modo estricto: %v", validationErrs[0])
			}
			for _, ve := range validationErrs {
				fmt.Fprintf(os.Stderr, "warning: %v\n", ve)
			}
			continue
		}

		person, err := mapRecordToPerson(header, record)
		if err != nil {
			fmt.Fprintf(os.Stderr, "warning: fila %d: %v\n", rowNum, err)
			continue
		}
		people = append(people, person)
	}

	if len(people) == 0 {
		return nil, fmt.Errorf("ninguna fila válida encontrada (%d errores)", len(allErrors))
	}

	if len(allErrors) > 0 {
		fmt.Fprintf(os.Stderr, "\n%d filas con errores, %d filas procesadas correctamente\n", len(allErrors), len(people))
	}

	return people, nil
}

Hay varios puntos importantes aquí:

  • Wrapping de errores con %w: permite que el código que llama inspeccione la causa raíz con errors.Is o errors.As. Es la forma estándar de encadenar errores en Go desde la versión 1.13.
  • Warnings a stderr: los mensajes de aviso van a os.Stderr para que no contaminen la salida JSON que va a stdout. Esto es fundamental si la herramienta se usa en un pipeline.
  • Resumen final: al terminar, el usuario ve cuántas filas se procesaron y cuántas fallaron. Información, no solo un exit code.

Compilar y distribuir el binario

Una de las ventajas prácticas de Go es que compila a un binario estático. Sin dependencias de runtime, sin necesidad de que la máquina destino tenga Go instalado. Copias el binario y funciona.

go build -o csvtojson main.go

Eso genera un ejecutable csvtojson para tu sistema operativo y arquitectura actual. Para distribuirlo a otras plataformas, puedes hacer cross-compilation:

# Linux AMD64
GOOS=linux GOARCH=amd64 go build -o csvtojson-linux-amd64 main.go

# macOS ARM (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o csvtojson-darwin-arm64 main.go

# Windows
GOOS=windows GOARCH=amd64 go build -o csvtojson.exe main.go

No necesitas Docker, ni una VM, ni un entorno de CI complejo. Go compila para cualquier plataforma desde cualquier plataforma. Es una de las razones por las que Go es tan popular para herramientas de línea de comandos.

Para reducir el tamaño del binario, puedes usar el flag -ldflags:

go build -ldflags="-s -w" -o csvtojson main.go

-s elimina la tabla de símbolos y -w elimina la información de debug DWARF. En una herramienta pequeña como esta pasas de unos 5-6 MB a 3-4 MB. No es crítico, pero es buena práctica.

Si quieres instalar la herramienta directamente en tu $GOPATH/bin:

go install

Y ya puedes ejecutar csvtojson desde cualquier directorio.


Extender la herramienta: filtrado, orden y más formatos

Una vez que tienes la base funcionando, hay extensiones naturales que puedes añadir sin cambiar la arquitectura. Cada una te obliga a aprender algo nuevo de Go.

Filtrar filas

Añade un flag -filter que acepte una expresión simple como ciudad=Madrid:

filterExpr := flag.String("filter", "", "filtrar filas (campo=valor)")

Y luego filtra después de la validación:

func filterPeople(people []Person, field, value string) []Person {
	var result []Person
	for _, p := range people {
		match := false
		switch field {
		case "nombre":
			match = strings.EqualFold(p.Nombre, value)
		case "ciudad":
			match = strings.EqualFold(p.Ciudad, value)
		case "email":
			match = strings.EqualFold(p.Email, value)
		}
		if match {
			result = append(result, p)
		}
	}
	return result
}

strings.EqualFold hace comparación case-insensitive. Es más robusto que convertir todo a minúsculas manualmente.

Ordenar resultados

Usa el paquete sort de la librería estándar:

import "sort"

func sortPeople(people []Person, field string, ascending bool) {
	sort.Slice(people, func(i, j int) bool {
		var less bool
		switch field {
		case "nombre":
			less = people[i].Nombre < people[j].Nombre
		case "edad":
			less = people[i].Edad < people[j].Edad
		case "ciudad":
			less = people[i].Ciudad < people[j].Ciudad
		default:
			less = people[i].Nombre < people[j].Nombre
		}
		if ascending {
			return less
		}
		return !less
	})
}

sort.Slice es la forma estándar de ordenar slices en Go. Recibe una función de comparación que devuelve true si el elemento i debe ir antes que el j.

Soporte para otros formatos de salida

Puedes añadir salida en formato YAML, NDJSON (una línea JSON por registro, útil para streaming) o incluso tablas de texto:

func toNDJSON(people []Person) ([]byte, error) {
	var buf bytes.Buffer
	encoder := json.NewEncoder(&buf)
	for _, p := range people {
		if err := encoder.Encode(p); err != nil {
			return nil, fmt.Errorf("error codificando persona: %w", err)
		}
	}
	return buf.Bytes(), nil
}

NDJSON es especialmente práctico cuando trabajas con herramientas como jq, porque cada línea es un JSON válido independiente. Puedes procesarlas con grep, head, tail o cualquier herramienta Unix estándar.


Código completo

Aquí está el programa completo, listo para compilar y ejecutar:

package main

import (
	"bytes"
	"encoding/csv"
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"strconv"
	"strings"
)

type Person struct {
	Nombre string `json:"nombre"`
	Edad   int    `json:"edad"`
	Email  string `json:"email"`
	Ciudad string `json:"ciudad"`
}

type ValidationError struct {
	Row     int
	Field   string
	Message string
}

func (e ValidationError) Error() string {
	return fmt.Sprintf("fila %d, campo '%s': %s", e.Row, e.Field, e.Message)
}

func validatePerson(row int, header []string, record []string) []ValidationError {
	var errs []ValidationError

	if len(record) != len(header) {
		errs = append(errs, ValidationError{
			Row:     row,
			Field:   "-",
			Message: fmt.Sprintf("número de campos incorrecto: tiene %d, se esperaban %d", len(record), len(header)),
		})
		return errs
	}

	fieldMap := make(map[string]string)
	for i, h := range header {
		fieldMap[h] = strings.TrimSpace(record[i])
	}

	if fieldMap["nombre"] == "" {
		errs = append(errs, ValidationError{Row: row, Field: "nombre", Message: "campo vacío"})
	}

	if fieldMap["edad"] == "" {
		errs = append(errs, ValidationError{Row: row, Field: "edad", Message: "campo vacío"})
	} else if _, err := strconv.Atoi(fieldMap["edad"]); err != nil {
		errs = append(errs, ValidationError{Row: row, Field: "edad", Message: fmt.Sprintf("no es un número válido: %q", fieldMap["edad"])})
	}

	if fieldMap["email"] == "" {
		errs = append(errs, ValidationError{Row: row, Field: "email", Message: "campo vacío"})
	} else if !strings.Contains(fieldMap["email"], "@") {
		errs = append(errs, ValidationError{Row: row, Field: "email", Message: "formato de email inválido"})
	}

	if fieldMap["ciudad"] == "" {
		errs = append(errs, ValidationError{Row: row, Field: "ciudad", Message: "campo vacío"})
	}

	return errs
}

func mapRecordToPerson(header []string, record []string) (Person, error) {
	if len(record) != len(header) {
		return Person{}, fmt.Errorf("la fila tiene %d campos, se esperaban %d", len(record), len(header))
	}

	fieldMap := make(map[string]string)
	for i, h := range header {
		fieldMap[h] = strings.TrimSpace(record[i])
	}

	edad, err := strconv.Atoi(fieldMap["edad"])
	if err != nil {
		return Person{}, fmt.Errorf("campo 'edad' no es un número válido: %q", fieldMap["edad"])
	}

	return Person{
		Nombre: fieldMap["nombre"],
		Edad:   edad,
		Email:  fieldMap["email"],
		Ciudad: fieldMap["ciudad"],
	}, nil
}

func processCSV(inputPath string, strict bool) ([]Person, error) {
	file, err := os.Open(inputPath)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, fmt.Errorf("el fichero '%s' no existe", inputPath)
		}
		if os.IsPermission(err) {
			return nil, fmt.Errorf("sin permisos para leer '%s'", inputPath)
		}
		return nil, fmt.Errorf("no se puede abrir '%s': %w", inputPath, err)
	}
	defer file.Close()

	reader := csv.NewReader(file)
	records, err := reader.ReadAll()
	if err != nil {
		return nil, fmt.Errorf("error al parsear CSV: %w", err)
	}

	if len(records) < 2 {
		return nil, fmt.Errorf("el fichero CSV está vacío o solo tiene cabeceras")
	}

	header := records[0]
	var people []Person
	var allErrors []ValidationError

	for i, record := range records[1:] {
		rowNum := i + 2

		validationErrs := validatePerson(rowNum, header, record)
		if len(validationErrs) > 0 {
			allErrors = append(allErrors, validationErrs...)
			if strict {
				return nil, fmt.Errorf("modo estricto: %v", validationErrs[0])
			}
			for _, ve := range validationErrs {
				fmt.Fprintf(os.Stderr, "warning: %v\n", ve)
			}
			continue
		}

		person, err := mapRecordToPerson(header, record)
		if err != nil {
			fmt.Fprintf(os.Stderr, "warning: fila %d: %v\n", rowNum, err)
			continue
		}
		people = append(people, person)
	}

	if len(people) == 0 {
		return nil, fmt.Errorf("ninguna fila válida encontrada (%d errores)", len(allErrors))
	}

	if len(allErrors) > 0 {
		fmt.Fprintf(os.Stderr, "\n%d filas con errores, %d filas procesadas correctamente\n", len(allErrors), len(people))
	}

	return people, nil
}

func toJSON(people []Person, pretty bool) ([]byte, error) {
	if pretty {
		return json.MarshalIndent(people, "", "  ")
	}
	return json.Marshal(people)
}

func toNDJSON(people []Person) ([]byte, error) {
	var buf bytes.Buffer
	encoder := json.NewEncoder(&buf)
	for _, p := range people {
		if err := encoder.Encode(p); err != nil {
			return nil, fmt.Errorf("error codificando registro: %w", err)
		}
	}
	return buf.Bytes(), nil
}

func main() {
	inputFile := flag.String("input", "", "fichero CSV de entrada (obligatorio)")
	outputFile := flag.String("output", "", "fichero JSON de salida (stdout si no se especifica)")
	pretty := flag.Bool("pretty", false, "formato JSON con indentación")
	strict := flag.Bool("strict", false, "abortar si hay errores de validación")
	format := flag.String("format", "json", "formato de salida: json o ndjson")
	flag.Parse()

	if *inputFile == "" {
		fmt.Fprintln(os.Stderr, "error: debes especificar un fichero de entrada con -input")
		flag.Usage()
		os.Exit(1)
	}

	people, err := processCSV(*inputFile, *strict)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}

	var output []byte
	switch *format {
	case "json":
		output, err = toJSON(people, *pretty)
	case "ndjson":
		output, err = toNDJSON(people)
	default:
		fmt.Fprintf(os.Stderr, "error: formato desconocido '%s' (usa 'json' o 'ndjson')\n", *format)
		os.Exit(1)
	}

	if err != nil {
		fmt.Fprintf(os.Stderr, "error al generar la salida: %v\n", err)
		os.Exit(1)
	}

	if *outputFile != "" {
		err = os.WriteFile(*outputFile, output, 0644)
		if err != nil {
			fmt.Fprintf(os.Stderr, "error al escribir '%s': %v\n", *outputFile, err)
			os.Exit(1)
		}
		fmt.Fprintf(os.Stderr, "JSON escrito en '%s' (%d registros)\n", *outputFile, len(people))
	} else {
		fmt.Println(string(output))
	}
}

Ejecutar y probar

# Compilar
go build -o csvtojson main.go

# Salida compacta por stdout
./csvtojson -input datos.csv

# Salida formateada a fichero
./csvtojson -input datos.csv -output resultado.json -pretty

# Modo estricto: falla al primer error
./csvtojson -input datos.csv -strict

# Formato NDJSON
./csvtojson -input datos.csv -format ndjson

Con nuestro CSV de ejemplo, la salida incluirá warnings para las filas con datos problemáticos y solo las filas válidas aparecerán en el JSON resultante:

$ ./csvtojson -input datos.csv -pretty
warning: fila 4, campo 'edad': campo vacío
warning: fila 5, campo 'nombre': campo vacío
warning: fila 5, campo 'email': formato de email inválido
warning: fila 6, campo 'edad': no es un número válido: "abc"

3 filas con errores, 2 filas procesadas correctamente
[
  {
    "nombre": "Ana García",
    "edad": 34,
    "email": "ana@example.com",
    "ciudad": "Madrid"
  },
  {
    "nombre": "Pedro López",
    "edad": 28,
    "email": "pedro@example.com",
    "ciudad": "Barcelona"
  }
]

Menos de 200 líneas y cero dependencias

Con menos de 200 líneas de Go has construido una herramienta de línea de comandos funcional que lee CSV, valida datos, convierte a JSON y soporta múltiples formatos de salida. Todo con la librería estándar. Sin dependencias externas, sin frameworks, sin magia. En el proceso has tocado los paquetes fundamentales del lenguaje: encoding/csv y encoding/json para la transformación de datos, flag para argumentos, strconv para conversión de tipos, y os para la interacción con el sistema de ficheros. Pero lo más importante es que has practicado el patrón if err != nil y el uso de structs con tags, que son la base de cualquier programa Go.

Este tipo de proyecto es exactamente lo que necesitas para asentar los fundamentos del lenguaje. No es un ejercicio teórico. Es una herramienta que puedes usar en tu día a día, extender según tus necesidades y compilar para cualquier plataforma con un solo comando.

Si quieres seguir practicando con proyectos reales, echa un vistazo a la lista de proyectos para aprender Go. Y si te interesa llevar las herramientas CLI más lejos, con subcomandos y autocompletado, el siguiente paso natural es construir una CLI en Go con bibliotecas especializadas.

OshyTech

Ingeniería backend y de datos orientada a sistemas escalables, automatización e IA.

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados