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.

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 goO 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.gzAñade esto a tu .bashrc o .zshrc:
export PATH=$PATH:/usr/local/go/bin
export PATH=$PATH:$(go env GOPATH)/binWindows
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/arm64Si 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-proyectoEse 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.0Eso 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
# FuncionaO compílalo:
go build -o mi-app
./mi-app
# Funcionago 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.goPara 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 = trueDeclaración corta (la que vas a usar el 90% del tiempo)
nombre := "Roger"
edad := 30
activo := trueEl 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()) // trueLa 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í:
if err != nil: es el patrón universal. Cada operación que puede fallar devuelve un error, y lo compruebas inmediatamente.%w(wrapping): envuelve el error original para añadir contexto sin perder la información del error subyacente. Esto es clave para debugging.defer: programa la ejecución dearchivo.Close()para cuando la función actual termine. Es el equivalente atry-with-resourcesen 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
Testy 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 -4Sabes 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.outEl ú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 tidyLimpia 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 != niles 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 hayconda, 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.


