Cómo empezar con Go si ya sabes programar

Guía práctica para empezar con Go si vienes de Python, Java o Kotlin. Instalación, estructura, módulos, funciones, structs y testing.

Cover for Cómo empezar con Go si ya sabes programar

Si ya programas en Python, Java o Kotlin, no necesitas otro tutorial de “Hola Mundo”. Lo que necesitas es entender las opiniones que Go toma por ti y por qué las toma. Go es un lenguaje que te obliga a hacer las cosas de una forma concreta, y si intentas pelear contra eso —como hice yo al principio— la experiencia es mala. Pero si lo aceptas, todo encaja con una velocidad que sorprende.

Go no tiene clases, no tiene excepciones, no tiene genéricos complejos, no tiene herencia. Y eso no es una limitación accidental: es una decisión de diseño deliberada. Cada una de esas ausencias existe para eliminar una categoría entera de problemas que Google encontraba al mantener código a escala. Creo que tu trabajo al empezar no es preguntar “¿dónde está la herencia?” sino entender qué alternativa propone Go y por qué funciona. Aunque reconozco que cuesta aceptarlo al principio.

Este artículo es la guía que me hubiera ahorrado tiempo cuando empecé con Go viniendo de Kotlin y Java. Va directo a lo que importa.


Instalación: cinco minutos y listo

Go tiene una de las instalaciones más limpias de cualquier lenguaje. No hay gestores de versiones obligatorios, no hay conflictos con la versión del sistema, no hay drama.

macOS

// Si usas Homebrew:
// brew install go

O descarga el .pkg desde go.dev/dl e instálalo directamente. Homebrew es más cómodo para actualizar, pero el instalador oficial funciona igual de bien.

Linux

# Descarga la última versión (ajusta la versión al momento)
wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.23.0.linux-amd64.tar.gz

Añade esto a tu .bashrc o .zshrc:

export PATH=$PATH:/usr/local/go/bin
export PATH=$PATH:$(go env GOPATH)/bin

Windows

Descarga el .msi desde go.dev/dl y ejecuta el instalador. Las variables de entorno se configuran automáticamente.

Verifica la instalación

go version
# go version go1.23.0 darwin/arm64

Si ves la versión, estás listo. Y siendo honestos, eso ya es un alivio. No necesitas un SDK manager, no necesitas elegir entre JDK 17, 21 o 22, no necesitas pyenv para evitar conflictos. Una versión, un binario, funciona.


Tu primer proyecto: go mod, main.go, go run

En Go no creas un proyecto con un generador ni con un framework CLI. Creas un directorio, inicializas un módulo y escribes código.

mkdir mi-primer-proyecto
cd mi-primer-proyecto
go mod init github.com/tu-usuario/mi-primer-proyecto

Ese go mod init crea un archivo go.mod que es el equivalente al pom.xml de Maven, al build.gradle.kts de Gradle o al requirements.txt de Python. Pero más simple:

module github.com/tu-usuario/mi-primer-proyecto

go 1.23.0

Eso es todo. No hay ficheros XML de 200 líneas. No hay plugins. No hay secciones de configuración que nunca tocas pero que rompen si las borras.

Ahora crea el archivo main.go:

package main

import "fmt"

func main() {
	fmt.Println("Funciona")
}

Y ejecútalo:

go run main.go
# Funciona

O compílalo:

go build -o mi-app
./mi-app
# Funciona

go run compila y ejecuta en un paso. go build genera un binario estático que puedes copiar a cualquier máquina con la misma arquitectura y ejecutar sin dependencias. No necesitas JVM, no necesitas intérprete de Python, no necesitas Docker para que funcione. Un solo archivo binario.

Si vienes de Java o Kotlin, ese detalle va a cambiar tu forma de desplegar aplicaciones. Si vienes de Python, vas a dejar de pelear con entornos virtuales y versiones de dependencias en producción.


Paquetes y visibilidad: la convención que sorprende a todo el mundo

En Go, la visibilidad de un identificador se decide por la primera letra de su nombre. Mayúscula: exportado (público). Minúscula: no exportado (privado al paquete).

package usuario

// Exportado: accesible desde otros paquetes
type Usuario struct {
	Nombre string
	Email  string
	edad   int // no exportado: solo visible dentro del paquete "usuario"
}

// Exportado
func NuevoUsuario(nombre, email string, edad int) Usuario {
	return Usuario{
		Nombre: nombre,
		Email:  email,
		edad:   edad,
	}
}

// No exportado
func validarEmail(email string) bool {
	return len(email) > 0 // simplificado
}

No hay palabras clave public, private, protected, internal. No hay __init__.py. No hay export ni module.exports. La convención es el mecanismo.

La primera vez que lo ves parece raro. Técnicamente no estaba equivocado al pensar que era una convención extraña. Pero después de una semana, te parece casi absurdo que otros lenguajes necesiten una palabra clave para algo que se resuelve con una mayúscula. Abres un fichero, ves una función con minúscula y sabes inmediatamente que es interna. No necesitas buscar el modificador de acceso.

Estructura de paquetes

Cada directorio es un paquete. No hay un directorio src obligatorio. La estructura más básica es:

mi-proyecto/
├── go.mod
├── main.go
├── usuario/
│   └── usuario.go
└── repositorio/
    └── repositorio.go

Para importar un paquete de tu propio proyecto:

package main

import (
	"fmt"
	"github.com/tu-usuario/mi-primer-proyecto/usuario"
)

func main() {
	u := usuario.NuevoUsuario("Roger", "roger@example.com", 30)
	fmt.Println(u.Nombre)
}

Fíjate en que el import usa la ruta completa del módulo + directorio. No hay aliases mágicos ni convenciones de barrel files. Si necesitas profundizar en cómo organizar algo más serio, tengo un artículo dedicado a estructura de proyecto y otro a módulos en Go.


Variables, tipos y zero values

Go es un lenguaje tipado estáticamente, pero no necesitas declarar el tipo siempre. Tiene inferencia de tipos que funciona bien en la mayoría de casos.

Declaración explícita

var nombre string = "Roger"
var edad int = 30
var activo bool = true

Declaración corta (la que vas a usar el 90% del tiempo)

nombre := "Roger"
edad := 30
activo := true

El operador := declara e inicializa. Solo funciona dentro de funciones, no a nivel de paquete. Es el equivalente a val en Kotlin con inferencia de tipos.

Zero values: todo tiene un valor por defecto

En Go, las variables declaradas sin inicializar no son null ni undefined. Tienen un zero value determinado por su tipo:

var s string   // ""
var n int      // 0
var f float64  // 0.0
var b bool     // false
var p *int     // nil (solo los punteros)

Esto elimina una categoría entera de NullPointerException. Si declaras un string, siempre tiene un valor: la cadena vacía. No hay sorpresas. Bueno, casi: los punteros sí pueden ser nil, así que el problema no desaparece del todo, pero se reduce mucho. Si vienes de Java, piensa en que Go hace por defecto lo que tú hacías manualmente al inicializar campos en el constructor. Si vienes de Kotlin, es como si todo tuviera un valor por defecto sin que tengas que declararlo.

Tipos básicos

// Enteros
var a int     // tamaño depende de la plataforma (32 o 64 bits)
var b int64   // 64 bits explícitamente
var c uint    // entero sin signo

// Decimales
var d float64 // el más usado
var e float32

// Texto
var f string  // inmutable, UTF-8 por defecto

// Booleano
var g bool

// Byte y Rune
var h byte  // alias de uint8
var i rune  // alias de int32 (un carácter Unicode)

Go no hace conversiones implícitas entre tipos numéricos. Si tienes un int y necesitas un int64, lo conviertes explícitamente:

var x int = 42
var y int64 = int64(x)

Esto molesta los primeros días. Después agradeces que el compilador te obligue a ser explícito en lugar de esconder conversiones que pierden precisión.


Funciones: retornos múltiples y returns con nombre

Las funciones en Go tienen una característica que en otros lenguajes necesitas librerías o workarounds para conseguir: retornos múltiples nativos.

Función básica

func sumar(a, b int) int {
	return a + b
}

Si los parámetros son del mismo tipo, puedes agruparlos. a, b int en lugar de a int, b int.

Retornos múltiples

func dividir(a, b float64) (float64, error) {
	if b == 0 {
		return 0, fmt.Errorf("no se puede dividir por cero")
	}
	return a / b, nil
}

Y al llamarla:

resultado, err := dividir(10, 3)
if err != nil {
	log.Fatal(err)
}
fmt.Println(resultado)

Este patrón valor, error es el corazón de Go. No hay try/catch, no hay excepciones, no hay throw. Cada función que puede fallar devuelve un error como segundo valor, y el código que la llama decide qué hacer con él. Inmediatamente. No tres niveles más arriba en un catch genérico. Ahí es donde la filosofía de Go se hace tangible.

Si vienes de Java, al principio sientes que escribes más código. Y es cierto, no voy a negarlo. Pero también es cierto que nunca pierdes de vista dónde puede fallar algo. No hay excepciones que se propagan silenciosamente hasta un handler genérico que loguea “algo salió mal” y traga el error.

Named returns

Go permite nombrar los valores de retorno:

func parsearConfig(ruta string) (config Config, err error) {
	data, err := os.ReadFile(ruta)
	if err != nil {
		return // devuelve los zero values de config y el error actual
	}

	err = json.Unmarshal(data, &config)
	return // devuelve config poblado y err (nil si todo fue bien)
}

Los named returns se inicializan con su zero value y puedes usar return sin argumentos. Son útiles en funciones cortas, pero en funciones largas pueden hacer el código más difícil de seguir. Mi recomendación: úsalos cuando la función tiene 15 líneas o menos. En funciones más grandes, sé explícito con lo que devuelves.


Structs y métodos

Go no tiene clases. Tiene structs con métodos asociados. Si vienes de la orientación a objetos, piensa en structs como clases sin herencia, sin constructores mágicos y —siendo honestos— sin la mitad de la complejidad que probablemente nunca necesitabas.

Definir un struct

type Tarea struct {
	ID          int
	Titulo      string
	Completada  bool
}

Crear instancias

// Forma literal (la más común)
t := Tarea{
	ID:         1,
	Titulo:     "Escribir artículo de Go",
	Completada: false,
}

// Los campos no mencionados toman su zero value
t2 := Tarea{Titulo: "Revisar código"}
// t2.ID == 0, t2.Completada == false

// Puntero al struct
t3 := &Tarea{
	ID:     3,
	Titulo: "Desplegar servicio",
}

No hay new, no hay constructores con quince parámetros, no hay builders. Si necesitas validación al crear, lo convencional es crear una función constructora:

func NuevaTarea(titulo string) (Tarea, error) {
	if titulo == "" {
		return Tarea{}, fmt.Errorf("el título no puede estar vacío")
	}
	return Tarea{
		Titulo: titulo,
	}, nil
}

Métodos

Los métodos se asocian a un tipo mediante un receiver:

// Receiver por valor (no modifica el struct original)
func (t Tarea) EstaCompleta() bool {
	return t.Completada
}

// Receiver por puntero (puede modificar el struct)
func (t *Tarea) Completar() {
	t.Completada = true
}

Y se usan exactamente como esperarías:

tarea := Tarea{Titulo: "Aprender Go"}
fmt.Println(tarea.EstaCompleta()) // false

tarea.Completar()
fmt.Println(tarea.EstaCompleta()) // true

La diferencia entre receiver por valor y por puntero es fundamental:

  • Valor: recibe una copia del struct. Útil para métodos que solo leen.
  • Puntero: recibe una referencia. Necesario para métodos que modifican estado.

Si vienes de Java o Kotlin, piensa que func (t *Tarea) Completar() es el equivalente a un método de instancia que modifica this. Y func (t Tarea) EstaCompleta() es como si hicieras una copia del objeto antes de consultarlo.

La convención en Go es que si algún método del tipo necesita un receiver por puntero, todos los métodos deberían usar receiver por puntero. Esto mantiene la consistencia y evita bugs sutiles.


Manejo de errores: lo justo para empezar

El manejo de errores en Go es un tema que merece un artículo completo, y tengo uno dedicado en errores en Go. Pero necesitas entender lo básico desde el primer día porque vas a escribir if err != nil en prácticamente todas las funciones.

El patrón básico

archivo, err := os.Open("config.json")
if err != nil {
	return fmt.Errorf("error abriendo config: %w", err)
}
defer archivo.Close()

Tres cosas importantes aquí:

  1. if err != nil: es el patrón universal. Cada operación que puede fallar devuelve un error, y lo compruebas inmediatamente.
  2. %w (wrapping): envuelve el error original para añadir contexto sin perder la información del error subyacente. Esto es clave para debugging.
  3. defer: programa la ejecución de archivo.Close() para cuando la función actual termine. Es el equivalente a try-with-resources en Java o al context manager de Python, pero más flexible.

Crear errores propios

import "errors"

var ErrUsuarioNoEncontrado = errors.New("usuario no encontrado")

func BuscarUsuario(id int) (Usuario, error) {
	// ... lógica de búsqueda
	if !encontrado {
		return Usuario{}, ErrUsuarioNoEncontrado
	}
	return usuario, nil
}

Y al manejarlo:

usuario, err := BuscarUsuario(42)
if errors.Is(err, ErrUsuarioNoEncontrado) {
	// manejar caso específico
}

Sí, es más verboso que un catch (UserNotFoundException e). Pero sabes exactamente en qué línea se produce el error, qué función lo devolvió, y qué contexto se añadió en cada nivel. No hay stack traces de 50 líneas donde el error real está en la línea 37 entre un mar de frames de Spring. Creo que ese trade-off compensa, aunque entiendo que no todo el mundo lo vea así al principio.

Para una inmersión completa en errores custom, wrapping, errors.Is, errors.As y patrones avanzados, lee el artículo de errores en Go.


Tu primer test: el paquete testing

Go incluye un framework de testing en la librería estándar. No necesitas instalar JUnit, no necesitas pytest, no necesitas configurar nada.

Convenciones

  • Los ficheros de test se llaman *_test.go
  • Las funciones de test empiezan por Test y reciben *testing.T
  • Se ejecutan con go test

Ejemplo completo

Supongamos que tienes un archivo calculadora.go:

package calculadora

func Sumar(a, b int) int {
	return a + b
}

func Dividir(a, b float64) (float64, error) {
	if b == 0 {
		return 0, fmt.Errorf("división por cero")
	}
	return a / b, nil
}

El test va en calculadora_test.go, en el mismo directorio:

package calculadora

import (
	"math"
	"testing"
)

func TestSumar(t *testing.T) {
	resultado := Sumar(2, 3)
	if resultado != 5 {
		t.Errorf("Sumar(2, 3) = %d; esperado 5", resultado)
	}
}

func TestDividir(t *testing.T) {
	resultado, err := Dividir(10, 3)
	if err != nil {
		t.Fatalf("error inesperado: %v", err)
	}
	esperado := 3.3333
	if math.Abs(resultado-esperado) > 0.001 {
		t.Errorf("Dividir(10, 3) = %f; esperado %f", resultado, esperado)
	}
}

func TestDividirPorCero(t *testing.T) {
	_, err := Dividir(10, 0)
	if err == nil {
		t.Error("se esperaba error al dividir por cero")
	}
}

Ejecuta los tests:

go test ./...
# ok  	github.com/tu-usuario/mi-proyecto/calculadora	0.003s

./... ejecuta los tests de todos los paquetes del proyecto. Sin esa convención, tendrías que especificar cada paquete manualmente.

Table-driven tests

El patrón idiomático en Go para probar múltiples casos es table-driven tests:

func TestSumar_TableDriven(t *testing.T) {
	tests := []struct {
		nombre   string
		a, b     int
		esperado int
	}{
		{"positivos", 2, 3, 5},
		{"con cero", 0, 5, 5},
		{"negativos", -1, -2, -3},
		{"mixto", -1, 5, 4},
	}

	for _, tt := range tests {
		t.Run(tt.nombre, func(t *testing.T) {
			resultado := Sumar(tt.a, tt.b)
			if resultado != tt.esperado {
				t.Errorf("Sumar(%d, %d) = %d; esperado %d",
					tt.a, tt.b, resultado, tt.esperado)
			}
		})
	}
}

t.Run crea subtests con nombre, lo que hace que la salida sea clara cuando algo falla:

--- FAIL: TestSumar_TableDriven/negativos (0.00s)
    calculadora_test.go:25: Sumar(-1, -2) = -3; esperado -4

Sabes exactamente qué caso falló sin leer un log de 200 líneas. Si vienes de JUnit con @ParameterizedTest, es el mismo concepto pero sin anotaciones ni dependencias adicionales.

Cobertura

go test -cover ./...
# ok  	github.com/tu-usuario/mi-proyecto/calculadora	0.003s	coverage: 85.7%

# Para ver qué líneas no están cubiertas:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

El último comando abre un navegador con un mapa visual de cobertura. Verde: cubierto. Rojo: no cubierto. Sin configurar Jacoco, sin plugins de Gradle, sin generar reportes XML que luego parsea otra herramienta.


Herramientas que vienen incluidas

Una de las mayores ventajas de Go es que las herramientas están integradas. No son plugins de terceros que hay que configurar, versionar y mantener.

go fmt

go fmt ./...

Formatea todo el código del proyecto según el estándar de Go. No hay debates sobre tabs vs. spaces, no hay ficheros de configuración de Prettier, no hay CI que falla porque alguien no ejecutó el formateador. En Go se usa tabs, punto. El formateo es parte del lenguaje, no una opinión del equipo. Y siendo honestos, que esa discusión ya venga zanjada de fábrica es un regalo.

go vet

go vet ./...

Analiza el código buscando errores comunes: argumentos incorrectos en fmt.Printf, comprobaciones de errores olvidadas, cosas que compilan pero que son probablemente bugs.

go mod tidy

go mod tidy

Limpia el go.mod y el go.sum: añade dependencias que faltan y elimina las que no se usan. Es el equivalente a ejecutar npm prune + npm dedupe en un solo comando.

Todo junto

La secuencia típica antes de hacer un commit:

go fmt ./...
go vet ./...
go test ./...

Tres comandos. Sin task runners, sin Makefiles de 500 líneas, sin scripts de CI que instalan herramientas que no vienen con el lenguaje. Todo ya está ahí.


Qué va a sorprenderte viniendo de otros lenguajes

Cada lenguaje tiene sus fricciones cuando empiezas. Estas son las que más tiempo me costaron a mí, y creo que a la mayoría le pasa algo parecido:

Desde Java/Kotlin

  • No hay herencia. Composición e interfaces. Siempre. Al principio parece limitante, pero después descubres que tus diseños acaban siendo más simples y más fáciles de cambiar.
  • No hay excepciones. El if err != nil es verboso, pero explícito. No hay handlers a tres niveles de distancia que traguen errores.
  • No hay genéricos complejos. Go 1.18 añadió genéricos, pero son básicos comparados con los de Java. Esto es intencional.
  • Compilación rápida. Un proyecto mediano compila en segundos. No en minutos. El feedback loop es inmediato.

Desde Python

  • Tipado estático. Después de años con types opcionales en Python, Go te obliga a declarar tipos. Al principio molesta, después agradeces que el compilador atrape errores antes de ejecutar.
  • Sin entorno virtual. No hay venv, no hay conda, no hay conflictos de versiones. Un binario, cero dependencias en runtime.
  • Sin magia. No hay decoradores, no hay metaclases, no hay monkey patching. El código hace lo que dice. Ni más ni menos.
  • Velocidad. Un programa en Go suele ser entre 10x y 100x más rápido que el equivalente en Python. Para scripts de automatización quizá no importa. Para servicios con tráfico real, es un antes y un después.

El lenguaje se defiende solo

Esto es solo la puerta de entrada. Go tiene mucho más debajo, y lo interesante de verdad empieza cuando pasas de escribir funciones sueltas a construir proyectos reales. Desde aquí, te recomiendo seguir con los fundamentos completos para tener una visión más amplia, después entender módulos y estructura de proyecto para cuando tu código crezca más allá de un main.go, y leer errores en Go cuanto antes para dominar el patrón que más vas a repetir.

Go se aprende construyendo. La documentación oficial es excelente, la librería estándar cubre el 80% de lo que necesitas, y el tooling integrado elimina toda la fricción de configuración que en otros ecosistemas te roba horas. Mi recomendación: monta un proyecto pequeño, escribe tests, despliega un binario y mide. La primera vez que compiles un servicio, lo metas en un contenedor de 15 MB y lo veas arrancar en menos de un segundo, creo que vas a entender por qué tanta gente se queda.

OshyTech

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

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados