Punteros en Go explicados sin drama

Punteros en Go con ejemplos reales: mutabilidad, métodos con receiver, rendimiento, nil y errores comunes. Sin complicaciones.

Cover for Punteros en Go explicados sin drama

Si vienes de Python o Java, llevas años trabajando con referencias sin pensarlo. Cada vez que pasas un objeto a una función, estás pasando una referencia al objeto original. Lo que modifiques dentro, se modifica fuera. No hay decisión consciente: el lenguaje lo hace por ti.

Go te obliga a ser explícito. Y eso, lejos de ser un problema, es una de las mejores decisiones de diseño del lenguaje. Cuando ves *User en una firma, sabes inmediatamente que esa función puede modificar el struct original. Cuando ves User a secas, sabes que trabaja con una copia. Sin ambigüedades, sin magia oculta.

Los punteros en Go no son los punteros de C. No hay aritmética de punteros, no hay malloc, no hay free. Son una herramienta concreta para controlar cuándo quieres compartir una referencia y cuándo prefieres trabajar con una copia independiente.


Qué es un puntero: * y & en 2 minutos

Un puntero es una variable que almacena la dirección de memoria de otra variable. En Go se usan dos operadores:

  • & — obtiene la dirección de una variable (crea un puntero hacia ella).
  • * — accede al valor al que apunta un puntero (desreferencia).
package main

import "fmt"

func main() {
    nombre := "Roger"
    puntero := &nombre // puntero es de tipo *string

    fmt.Println(nombre)    // "Roger"
    fmt.Println(puntero)   // 0xc0000140a0 (dirección de memoria)
    fmt.Println(*puntero)  // "Roger" (desreferencia)

    *puntero = "Otro nombre"
    fmt.Println(nombre)    // "Otro nombre" — hemos modificado el original
}

El tipo de puntero en este caso es *string. El asterisco delante del tipo indica que es un puntero a ese tipo. No hay nada más que entender aquí: & para obtener la dirección, * para leer o escribir lo que hay en esa dirección.

Declaración explícita

También puedes declarar un puntero sin inicializarlo:

var p *int // puntero a int, valor por defecto: nil
fmt.Println(p) // <nil>

Un puntero no inicializado vale nil. Acceder a *p cuando p es nil provoca un panic. Volveremos a esto más adelante.


Por qué Go usa punteros: semántica de valor por defecto

En la mayoría de lenguajes que usas a diario, los objetos se pasan por referencia. En Go, todo se pasa por valor. Cuando llamas a una función con un struct, Go copia el struct entero.

type Config struct {
    MaxRetries int
    Timeout    int
}

func duplicarTimeout(c Config) {
    c.Timeout *= 2
}

func main() {
    cfg := Config{MaxRetries: 3, Timeout: 30}
    duplicarTimeout(cfg)
    fmt.Println(cfg.Timeout) // 30 — no ha cambiado
}

La función duplicarTimeout recibe una copia de cfg. Modifica la copia, la original queda intacta. Si quieres que la función modifique el original, necesitas un puntero:

func duplicarTimeout(c *Config) {
    c.Timeout *= 2
}

func main() {
    cfg := Config{MaxRetries: 3, Timeout: 30}
    duplicarTimeout(&cfg)
    fmt.Println(cfg.Timeout) // 60 — ahora sí
}

Esta semántica de valor por defecto es deliberada. Te fuerza a tomar la decisión consciente de cuándo algo es mutable desde fuera. En un proyecto grande, eso se traduce en menos bugs por mutación inesperada.


Value receivers vs pointer receivers

Cuando defines métodos en un struct, tienes que elegir entre value receiver y pointer receiver. La diferencia es fundamental y afecta directamente a cómo se comporta tu código.

Value receiver

type Rectangulo struct {
    Ancho  float64
    Alto   float64
}

func (r Rectangulo) Area() float64 {
    return r.Ancho * r.Alto
}

El método Area recibe una copia del Rectangulo. No puede modificarlo, solo leerlo. Perfecto para métodos que calculan algo sin cambiar estado.

Pointer receiver

func (r *Rectangulo) Escalar(factor float64) {
    r.Ancho *= factor
    r.Alto *= factor
}

El método Escalar recibe un puntero. Modifica el struct original. Si usaras un value receiver aquí, los cambios se perderían.

La regla práctica

La convención en Go es clara:

  1. Si el método modifica el receiver → pointer receiver. Sin discusión.
  2. Si el struct es grande → pointer receiver, para evitar copias innecesarias.
  3. Si algún método del tipo usa pointer receiver → todos deberían usar pointer receiver, por consistencia.
  4. Si el struct es pequeño e inmutable → value receiver funciona bien.
// Consistencia: si uno usa pointer receiver, todos deberían
type Usuario struct {
    ID     int
    Nombre string
    Email  string
    Config ConfigCompleja
}

func (u *Usuario) CambiarEmail(nuevo string) {
    u.Email = nuevo
}

func (u *Usuario) NombreCompleto() string {
    return u.Nombre // Aunque no modifica, usamos pointer receiver por consistencia
}

Un detalle que a veces confunde: Go te permite llamar métodos con pointer receiver en un valor (no puntero) y viceversa. El compilador hace la conversión automática:

rect := Rectangulo{Ancho: 10, Alto: 5}
rect.Escalar(2) // Go convierte automáticamente a (&rect).Escalar(2)

Pero esto solo funciona cuando el compilador puede tomar la dirección de la variable. No funciona con valores devueltos directamente de funciones si no se guardan en una variable antes.


Cuándo usar punteros: mutabilidad, structs grandes, estado compartido

No todos los escenarios necesitan punteros. Aquí van los tres casos claros donde sí los necesitas.

1. Mutabilidad

El caso más obvio. Si una función o método necesita modificar un argumento, necesitas un puntero.

func resetearContador(c *Contador) {
    c.Valor = 0
    c.UltimoReset = time.Now()
}

2. Structs grandes

Copiar un struct de 3 campos es barato. Copiar uno con 20 campos, slices internos y maps ya no tanto. Para structs grandes, un puntero evita la copia:

type InformeCompleto struct {
    Titulo      string
    Secciones   []Seccion
    Metadatos   map[string]string
    Contenido   []byte
    Historial   []Revision
    // ... 15 campos más
}

// Puntero para evitar copiar toda esta estructura
func procesarInforme(informe *InformeCompleto) error {
    // ...
    return nil
}

3. Estado compartido

Cuando múltiples goroutines o componentes necesitan acceder y modificar el mismo dato:

type Cache struct {
    mu    sync.RWMutex
    datos map[string]string
}

func NuevaCache() *Cache {
    return &Cache{
        datos: make(map[string]string),
    }
}

func (c *Cache) Set(clave, valor string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.datos[clave] = valor
}

func (c *Cache) Get(clave string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, ok := c.datos[clave]
    return val, ok
}

Aquí, Cache siempre se pasa como puntero. Copiar un struct con un sync.Mutex dentro es un bug garantizado — la copia tendría su propio mutex independiente, rompiendo toda la sincronización.

Cuándo NO usar punteros

  • Tipos básicos (int, string, bool): cópialos sin miedo. Son pequeños y la copia es prácticamente gratis.
  • Structs pequeños e inmutables: si solo tiene 2-3 campos y ningún método lo modifica, una copia es más simple y segura.
  • Cuando quieres garantizar inmutabilidad: pasar una copia asegura que nadie modifica el original desde otro sitio.

Punteros nil: el error del billón de dólares (y cómo Go lo mitiga)

Tony Hoare llamó a la referencia nula su “error de mil millones de dólares”. Go no elimina el problema — los punteros nil existen — pero te da herramientas para mitigarlo.

El panic clásico

var u *Usuario
fmt.Println(u.Nombre) // panic: runtime error: invalid memory address or nil pointer dereference

Este es probablemente el panic más común en Go. Intentar acceder a un campo o método de un puntero nil revienta en tiempo de ejecución.

Cómo protegerte

1. Comprueba nil antes de usar:

func enviarNotificacion(u *Usuario) error {
    if u == nil {
        return fmt.Errorf("usuario es nil")
    }
    // proceder con seguridad
    return enviarEmail(u.Email)
}

2. Devuelve errores en lugar de punteros nil sin contexto:

// Mal: el caller no sabe por qué es nil
func buscarUsuario(id int) *Usuario {
    // ...
    return nil
}

// Bien: el caller sabe exactamente qué pasó
func buscarUsuario(id int) (*Usuario, error) {
    u, err := db.Query(id)
    if err != nil {
        return nil, fmt.Errorf("error buscando usuario %d: %w", id, err)
    }
    if u == nil {
        return nil, fmt.Errorf("usuario %d no encontrado", id)
    }
    return u, nil
}

El patrón (*T, error) es idiomático en Go y es tu principal defensa contra nil inesperados. Si tu función puede fallar, devuelve un error siempre.

3. Usa el zero value a tu favor:

En muchos casos, puedes evitar punteros completamente devolviendo el zero value del struct junto con un error:

func buscarConfig(nombre string) (Config, error) {
    cfg, ok := configs[nombre]
    if !ok {
        return Config{}, fmt.Errorf("config %q no encontrada", nombre)
    }
    return cfg, nil
}

Aquí no hay puntero, no hay riesgo de nil. El caller recibe una copia o un zero value. Más simple, más seguro.


Punteros y argumentos de función

Cuando pasas un puntero a una función, la función recibe una copia del puntero (que apunta a la misma dirección de memoria). Esto tiene implicaciones importantes.

Modificar lo apuntado funciona

func cambiarNombre(u *Usuario) {
    u.Nombre = "Nuevo" // modifica el struct original
}

Reasignar el puntero no afecta al caller

func reemplazar(u *Usuario) {
    u = &Usuario{Nombre: "Otro"} // solo cambia la copia local del puntero
}

func main() {
    u := &Usuario{Nombre: "Roger"}
    reemplazar(u)
    fmt.Println(u.Nombre) // "Roger" — no ha cambiado
}

Dentro de reemplazar, u es una variable local que contiene una copia del puntero. Reasignarla no afecta al puntero original en main. Solo afecta a la variable local.

Slices, maps y channels: ya son referencias

Un dato que genera confusión: los slices, maps y channels ya contienen internamente un puntero a los datos subyacentes. Pasarlos a una función no copia los datos, solo la cabecera del slice/map/channel.

func agregarElemento(s []int) []int {
    return append(s, 42)
}

func modificarElemento(s []int) {
    s[0] = 999 // modifica el array subyacente original
}

No necesitas *[]int para modificar los elementos de un slice. Pero sí lo necesitas si quieres que la función cambie el slice en sí (su longitud o capacidad) de forma visible para el caller — por eso append devuelve el nuevo slice en lugar de modificar el original directamente.


Punteros a punteros: cuándo los necesitas (casi nunca)

Un puntero a puntero (**T) es exactamente lo que suena: un puntero que apunta a otro puntero. En Go, es extremadamente raro necesitarlos.

El caso legítimo

El único escenario donde tiene sentido es cuando una función necesita reasignar el puntero del caller:

func inicializarSiNil(pp **Config) {
    if *pp == nil {
        *pp = &Config{
            MaxRetries: 3,
            Timeout:    30,
        }
    }
}

func main() {
    var cfg *Config // nil
    inicializarSiNil(&cfg)
    fmt.Println(cfg.Timeout) // 30
}

Por qué casi nunca lo necesitas

En la práctica, hay formas más claras de resolver esto:

// Mejor: devolver el puntero directamente
func inicializarSiNil(cfg *Config) *Config {
    if cfg == nil {
        return &Config{
            MaxRetries: 3,
            Timeout:    30,
        }
    }
    return cfg
}

func main() {
    var cfg *Config
    cfg = inicializarSiNil(cfg)
    fmt.Println(cfg.Timeout) // 30
}

Este patrón es mucho más legible y no requiere punteros dobles. Si te encuentras escribiendo **T en Go, para y piensa si hay una alternativa más simple. Casi siempre la hay.


Errores comunes con punteros

1. Puntero a variable de bucle

Este es un clásico que ha atrapado a mucha gente. En versiones de Go anteriores a 1.22, la variable del bucle se reutilizaba en cada iteración:

// BUG en Go < 1.22
nombres := []string{"Ana", "Luis", "Marta"}
punteros := make([]*string, len(nombres))

for i, nombre := range nombres {
    punteros[i] = &nombre // todos apuntan a la misma variable!
}

for _, p := range punteros {
    fmt.Println(*p) // "Marta", "Marta", "Marta"
}

La variable nombre se reutilizaba en cada iteración, así que todos los punteros apuntaban a la misma dirección. Al final del bucle, esa dirección contiene “Marta”.

Solución clásica (pre Go 1.22):

for i, nombre := range nombres {
    n := nombre // copia local
    punteros[i] = &n
}

En Go 1.22+ este problema está resuelto: cada iteración crea una nueva variable. Pero si mantienes código que compila con versiones anteriores, sigue siendo relevante.

2. Nil dereference en cadenas de acceso

type Empresa struct {
    Director *Persona
}

type Persona struct {
    Direccion *Direccion
}

type Direccion struct {
    Ciudad string
}

func obtenerCiudad(e *Empresa) string {
    // PANIC si e, e.Director o e.Director.Direccion son nil
    return e.Director.Direccion.Ciudad
}

Cada acceso a un puntero en la cadena puede provocar un panic. La solución es comprobar cada nivel:

func obtenerCiudad(e *Empresa) string {
    if e == nil || e.Director == nil || e.Director.Direccion == nil {
        return ""
    }
    return e.Director.Direccion.Ciudad
}

Sí, es verboso. Pero es explícito. Go prefiere la verbosidad a la magia que esconde explosiones en tiempo de ejecución.

3. Copiar un struct con mutex o recursos internos

type Pool struct {
    mu      sync.Mutex
    conns   []*Connection
}

func hacerAlgo(p Pool) { // BUG: copia el mutex
    p.mu.Lock()
    defer p.mu.Unlock()
    // ...
}

Copiar un sync.Mutex crea un mutex independiente. Las dos copias pueden bloquearse simultáneamente, que es exactamente lo contrario de lo que querías. Siempre pasa structs con mutex como punteros. El compilador no te avisa de esto — hay que saberlo.

Si usas go vet, detectará algunos de estos casos, pero no todos. El testing con -race también ayuda a encontrar estos problemas.


Rendimiento: cuándo copiar está bien y cuándo los punteros ayudan

Hay una idea extendida de que “punteros = más rápido”. No siempre es cierto.

Cuándo copiar es mejor

  • Structs pequeños (< ~64 bytes, 3-4 campos): la copia es tan rápida que el overhead de indirección del puntero puede ser peor.
  • Datos de solo lectura: las copias son cache-friendly. El procesador puede acceder a datos contiguos en memoria más rápido que persiguiendo punteros.
  • Concurrencia: cada goroutine con su propia copia no necesita sincronización.

Cuándo los punteros ayudan

  • Structs grandes: copiar 1 KB de datos en cada llamada a función sí tiene coste.
  • Datos compartidos: si varias goroutines necesitan leer y escribir el mismo estado.
  • Interfaces: cuando asignas un struct grande a una interfaz, Go necesita hacer una copia. Un puntero evita esa copia.

Medir, no asumir

Go te da herramientas de benchmark integradas. Si dudas del impacto en rendimiento, mide:

func BenchmarkValor(b *testing.B) {
    cfg := Config{MaxRetries: 3, Timeout: 30}
    for i := 0; i < b.N; i++ {
        procesarPorValor(cfg)
    }
}

func BenchmarkPuntero(b *testing.B) {
    cfg := &Config{MaxRetries: 3, Timeout: 30}
    for i := 0; i < b.N; i++ {
        procesarPorPuntero(cfg)
    }
}

En structs pequeños, la diferencia será ruido estadístico. En structs de cientos de bytes, la diferencia será medible. La regla: no optimices antes de medir, y no uses punteros “por rendimiento” sin datos que lo respalden.

Escape analysis

Go decide en tiempo de compilación si una variable se queda en el stack o se mueve al heap. Cuando tomas la dirección de una variable local y la devuelves como puntero, esa variable “escapa” al heap:

func crearUsuario() *Usuario {
    u := Usuario{Nombre: "Roger"} // escapa al heap porque devolvemos su dirección
    return &u
}

Las asignaciones en el heap son más caras que en el stack y generan trabajo para el garbage collector. Puedes ver las decisiones del compilador con:

go build -gcflags="-m" ./...

Esto no significa que debas evitar devolver punteros. Significa que debes ser consciente de que no es gratis, y que a veces devolver un valor (copia) es más eficiente que devolver un puntero que fuerza una asignación en el heap.


Guía de decisión: cuándo usar punteros

Después de años trabajando con Go, esta es la lista que uso mentalmente antes de decidir si un parámetro o receiver debería ser un puntero:

Usa puntero cuando:

  • El método o función necesita modificar el argumento.
  • El struct contiene un sync.Mutex u otros campos que no deben copiarse.
  • El struct es grande (> 5-6 campos o contiene slices/maps pesados).
  • Necesitas representar la ausencia de valor (nil).
  • Múltiples goroutines necesitan acceder al mismo dato.
  • Algún otro método del tipo ya usa pointer receiver (consistencia).

Usa valor cuando:

  • El struct es pequeño e inmutable.
  • Quieres garantizar que nadie modifica el original.
  • Es un tipo básico (int, string, bool, float64).
  • Es un slice, map o channel (ya contienen referencias internas).
  • Es una función pura sin efectos secundarios.

Regla de oro: empieza con valores. Cambia a punteros cuando tengas una razón concreta. Si la razón es “por rendimiento” y no has hecho un benchmark, probablemente no necesitas el puntero.


El valor por defecto es valor, y eso es bueno

Los punteros en Go no son el monstruo que parecen si vienes de lenguajes con recolector de basura y paso por referencia automático. Son una herramienta explícita que te da control sobre algo que otros lenguajes esconden: cuándo trabajas con una copia y cuándo con la referencia original.

La semántica de valor por defecto de Go es, en mi opinión, una de sus mayores fortalezas. Te obliga a pensar en la mutabilidad de cada función, cada método, cada struct. Eso hace que el código sea más predecible y más fácil de razonar, especialmente en proyectos con concurrencia.

No conviertas todo a punteros por defecto. No evites los punteros por miedo. Usa las reglas de esta guía, mide cuando tengas dudas, y escribe código que cualquier persona del equipo pueda leer y entender sin necesidad de rastrear mutaciones ocultas.

Si quieres profundizar en la base de los punteros, empieza por entender bien los structs en Go y cómo funciona el manejo de errores — ambos temas están directamente conectados con las decisiones que tomas sobre punteros en el día a día.

OshyTech

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

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados