Go para tareas pesadas: cuándo mejora a Python y cuándo no
Comparación práctica de Go y Python en scripts pesados, concurrencia, scraping, ETL y procesos batch. Cuándo merece el cambio.

Reescribí un scraper de Python en Go y pasó de 45 minutos a 3. Tres minutos. El scraper recorría unas 80.000 páginas de un catálogo público, parseaba el HTML y guardaba los resultados en un JSON por lotes. En Python, con requests y BeautifulSoup, funcionaba bien pero era lento. Las peticiones se hacían secuencialmente, el GIL limitaba cualquier intento de paralelismo real y el uso de memoria crecía de forma constante. En Go, con goroutines, net/http y un pool de workers, el mismo trabajo terminaba en una fracción del tiempo con un consumo de memoria estable.
Pero también intenté reescribir un pipeline de datos que transformaba CSVs con pandas, aplicaba limpieza con numpy y generaba reportes con matplotlib. Y me arrepentí. Tardé tres veces más en escribirlo, el código era el doble de largo y el resultado no era significativamente más rápido porque el cuello de botella estaba en I/O de disco, no en CPU.
Y siendo honestos, esta es la parte que nadie te cuenta cuando lees los benchmarks: Go no es mejor que Python en todo. Es mejor en cosas específicas. Creo que saber cuándo merece la pena el cambio es lo que separa una decisión técnica buena de una reescritura que no aporta nada.
Dónde Python empieza a sufrir
Python es un lenguaje extraordinario para prototipado, automatización y data science. No tengo ninguna duda de eso. Pero tiene limitaciones reales cuando le pides rendimiento sostenido en tareas pesadas.
El GIL: el elefante en la habitación
El Global Interpreter Lock (GIL) es el mecanismo que garantiza que solo un hilo ejecute bytecode de Python a la vez. Esto simplifica la gestión de memoria del intérprete, pero tiene una consecuencia directa: el multithreading en Python no aprovecha múltiples cores para trabajo CPU-bound.
import threading
import time
def cpu_heavy(n):
"""Simula trabajo CPU-bound."""
total = 0
for i in range(n):
total += i * i
return total
start = time.time()
threads = []
for _ in range(4):
t = threading.Thread(target=cpu_heavy, args=(50_000_000,))
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"4 threads: {time.time() - start:.2f}s")
# Comparación secuencial
start = time.time()
for _ in range(4):
cpu_heavy(50_000_000)
print(f"Secuencial: {time.time() - start:.2f}s")Ejecuta esto y verás que la versión con 4 threads tarda lo mismo o más que la secuencial. El GIL serializa el trabajo CPU-bound. Puedes usar multiprocessing, sí, pero eso tiene overhead de serialización entre procesos y consume mucha más memoria porque cada proceso copia el intérprete completo.
Para I/O-bound (peticiones HTTP, lectura de ficheros), asyncio y aiohttp son buenas soluciones. Pero cuando mezclas I/O con procesamiento significativo de los datos recibidos, el modelo de Python empieza a crujir.
Despliegue: el infierno de las dependencias
Cualquiera que haya desplegado scripts de Python en producción conoce el ritual:
- Creas un
venvo usaspoetry/pipenv/uv. - Instalas dependencias. Algunas necesitan compiladores C porque son wrappers de librerías nativas.
- Te aseguras de que la versión de Python del servidor coincide con la de desarrollo.
- Empaquetas todo en un Docker para no volverte loco.
- La imagen de Docker pesa 800 MB porque incluye el intérprete, las dependencias y las librerías del sistema.
Funciona, pero tiene fricción. Para un servicio en producción con CI/CD, es manejable. Pero para un script de procesamiento batch que necesitas ejecutar en 10 servidores distintos, la situación cambia. Ahí es donde empieza a doler.
Consumo de memoria
Python no es eficiente con la memoria. Un dict en Python consume significativamente más memoria que una estructura equivalente en un lenguaje con tipado estático. Cuando procesas millones de registros en memoria, esto importa. He visto scripts de procesamiento batch en Python que necesitaban 8 GB de RAM para un trabajo que en Go se hacía con 500 MB.
Dónde Go marca la diferencia
Go no es un lenguaje bonito. No tiene la expresividad de Python, ni la ergonomía de Kotlin, ni la potencia del sistema de tipos de Rust. No voy a pretender lo contrario. Pero para ciertas tareas pesadas, tiene ventajas técnicas reales que conviene entender.
Concurrencia nativa con goroutines
Las goroutines son hilos ligeros gestionados por el runtime de Go. Son baratas de crear (unos pocos KB de stack inicial), el scheduler las distribuye entre los cores disponibles y la comunicación entre ellas se hace con channels. No hay GIL. No hay overhead de serialización entre procesos. Simplemente funcionan.
package main
import (
"fmt"
"sync"
"time"
)
func cpuHeavy(n int) int {
total := 0
for i := 0; i < n; i++ {
total += i * i
}
return total
}
func main() {
start := time.Now()
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
cpuHeavy(50_000_000)
}()
}
wg.Wait()
fmt.Printf("4 goroutines: %v\n", time.Since(start))
// Comparación secuencial
start = time.Now()
for i := 0; i < 4; i++ {
cpuHeavy(50_000_000)
}
fmt.Printf("Secuencial: %v\n", time.Since(start))
}Aquí sí verás una diferencia real. Las 4 goroutines se ejecutan en paralelo en distintos cores. En una máquina de 4 cores, el tiempo con goroutines será aproximadamente una cuarta parte del secuencial. En Python, recuerda, era el mismo.
Si vienes de Python y quieres entender bien el modelo de concurrencia en Go, ahí tienes una guía completa. Pero el resumen es: la concurrencia en Go es un ciudadano de primera clase del lenguaje, no un parche sobre un runtime que no fue diseñado para ello.
Binario único: compila y ejecuta
GOOS=linux GOARCH=amd64 go build -o scraper .
scp scraper servidor:/usr/local/bin/
ssh servidor "scraper --config /etc/scraper.yaml"Eso es todo. Un binario estático que incluye todo lo que necesita. Sin runtime, sin dependencias, sin contenedores obligatorios. Funciona en cualquier máquina con el mismo OS y arquitectura. Para herramientas de línea de comandos, scripts de procesamiento batch y utilidades que necesitas distribuir a múltiples servidores, esto es un cambio de paradigma respecto a Python.
Eficiencia de memoria
Go usa structs con layout fijo en memoria. No hay overhead de diccionarios, no hay boxing de tipos primitivos, no hay recolector de basura generacional como el de Python (Go usa un GC concurrent mark-and-sweep con pausas muy bajas). Para procesar grandes volúmenes de datos en memoria, la diferencia es significativa.
Comparación práctica: scraping HTTP
Vamos al caso que mencioné al principio. Imagina que necesitas scrapear 10.000 URLs, parsear el HTML y extraer datos específicos.
Python con asyncio y aiohttp
import asyncio
import aiohttp
from bs4 import BeautifulSoup
async def fetch_and_parse(session, url):
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
if resp.status == 200:
html = await resp.text()
soup = BeautifulSoup(html, "lxml")
title = soup.find("title")
return {"url": url, "title": title.text if title else ""}
except Exception as e:
return {"url": url, "error": str(e)}
async def main():
urls = [f"https://example.com/page/{i}" for i in range(10_000)]
connector = aiohttp.TCPConnector(limit=50)
async with aiohttp.ClientSession(connector=connector) as session:
semaphore = asyncio.Semaphore(50)
async def bounded_fetch(url):
async with semaphore:
return await fetch_and_parse(session, url)
results = await asyncio.gather(*[bounded_fetch(u) for u in urls])
print(f"Procesados: {len(results)}")
asyncio.run(main())Este código funciona bien. asyncio es eficiente para I/O concurrente. Pero hay matices:
BeautifulSoupes síncrono. El parseo del HTML bloquea el event loop. Con 10.000 páginas, ese tiempo se acumula.- Si el parseo es pesado (extraer múltiples elementos, navegar el DOM), estás CPU-bound dentro de un modelo async.
- El consumo de memoria crece porque
asyncio.gathermantiene todas las coroutines y sus resultados en memoria.
Go con goroutines y worker pool
package main
import (
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/net/html"
)
type Result struct {
URL string
Title string
Err error
}
func extractTitle(body io.Reader) string {
tokenizer := html.NewTokenizer(body)
inTitle := false
for {
tt := tokenizer.Next()
switch tt {
case html.ErrorToken:
return ""
case html.StartTagToken:
t := tokenizer.Token()
if t.Data == "title" {
inTitle = true
}
case html.TextToken:
if inTitle {
return strings.TrimSpace(tokenizer.Token().Data)
}
}
}
}
func worker(id int, urls <-chan string, results chan<- Result, wg *sync.WaitGroup, client *http.Client) {
defer wg.Done()
for url := range urls {
resp, err := client.Get(url)
if err != nil {
results <- Result{URL: url, Err: err}
continue
}
title := extractTitle(resp.Body)
resp.Body.Close()
results <- Result{URL: url, Title: title}
}
}
func main() {
start := time.Now()
urls := make([]string, 10_000)
for i := range urls {
urls[i] = fmt.Sprintf("https://example.com/page/%d", i)
}
urlChan := make(chan string, 100)
results := make(chan Result, 100)
client := &http.Client{Timeout: 10 * time.Second}
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go worker(i, urlChan, results, &wg, client)
}
// Enviar URLs
go func() {
for _, u := range urls {
urlChan <- u
}
close(urlChan)
}()
// Cerrar results cuando terminen los workers
go func() {
wg.Wait()
close(results)
}()
var collected []Result
for r := range results {
collected = append(collected, r)
}
fmt.Printf("Procesados: %d en %v\n", len(collected), time.Since(start))
}La diferencia clave aquí no es solo la velocidad de las peticiones HTTP (ambos pueden hacer 50 concurrentes). Es que el parseo del HTML en Go ocurre en paralelo real dentro de cada goroutine, mientras que en Python el parseo de BeautifulSoup serializa el trabajo en el event loop. Con 10.000 páginas, ese parseo acumulado marca una diferencia importante.
En mis pruebas reales con un scraper de catálogo:
| Métrica | Python (asyncio) | Go (worker pool) |
|---|---|---|
| Tiempo total | ~45 min | ~3 min |
| Memoria pico | ~1.2 GB | ~180 MB |
| CPU utilizada | 1 core (GIL) | 4 cores |
| Tamaño desplegable | Docker ~800 MB | Binario ~12 MB |
Los números varían según el caso, pero la proporción es representativa. Si tu scraper tiene una fase de parseo significativa, Go va a ser más rápido porque paraleliza de verdad.
Comparación práctica: procesamiento de ficheros CSV/JSON
Otro caso habitual: leer un CSV de varios millones de filas, transformar los datos y escribir el resultado.
Python con csv estándar
import csv
import json
from datetime import datetime
def process_csv(input_path, output_path):
results = []
with open(input_path, "r") as f:
reader = csv.DictReader(f)
for row in reader:
# Transformación: limpiar, convertir tipos, filtrar
amount = float(row["amount"])
if amount <= 0:
continue
results.append({
"id": row["id"],
"amount": round(amount * 1.21, 2), # Aplicar IVA
"date": datetime.strptime(row["date"], "%Y-%m-%d").isoformat(),
"category": row["category"].strip().lower(),
})
with open(output_path, "w") as f:
json.dump(results, f)
print(f"Procesados: {len(results)} registros")Go equivalente
package main
import (
"encoding/csv"
"encoding/json"
"fmt"
"math"
"os"
"strconv"
"strings"
"time"
)
type Record struct {
ID string `json:"id"`
Amount float64 `json:"amount"`
Date string `json:"date"`
Category string `json:"category"`
}
func processCSV(inputPath, outputPath string) error {
f, err := os.Open(inputPath)
if err != nil {
return err
}
defer f.Close()
reader := csv.NewReader(f)
headers, err := reader.Read()
if err != nil {
return err
}
// Mapear índices de columnas
idx := make(map[string]int)
for i, h := range headers {
idx[h] = i
}
var results []Record
for {
row, err := reader.Read()
if err != nil {
break
}
amount, _ := strconv.ParseFloat(row[idx["amount"]], 64)
if amount <= 0 {
continue
}
date, _ := time.Parse("2006-01-02", row[idx["date"]])
results = append(results, Record{
ID: row[idx["id"]],
Amount: math.Round(amount*1.21*100) / 100,
Date: date.Format(time.RFC3339),
Category: strings.ToLower(strings.TrimSpace(row[idx["category"]])),
})
}
out, err := os.Create(outputPath)
if err != nil {
return err
}
defer out.Close()
return json.NewEncoder(out).Encode(results)
}
func main() {
start := time.Now()
if err := processCSV("data.csv", "output.json"); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Procesado en %v\n", time.Since(start))
}Para un CSV de 5 millones de filas, Go será más rápido (típicamente 2-4x) y usará menos memoria. Pero la pregunta interesante es otra: para este caso, merece la pena?
Si tu script de procesamiento CSV se ejecuta una vez al día como un cron job, y Python lo hace en 2 minutos, creo que no tiene sentido reescribirlo en Go para que tarde 40 segundos. El código de Python es más corto, más fácil de modificar y cualquier persona del equipo con conocimientos de data lo entiende.
Pero la situación cambia si procesas ficheros de 50 millones de filas cada hora y el proceso Python tarda 30 minutos y consume 12 GB de RAM. Ahí la conversión a Go empieza a tener sentido económico real: menos tiempo de cómputo, instancias más pequeñas, menor coste de infraestructura.
Cuándo Go NO ayuda: el ecosistema de datos de Python
Y aquí viene la parte que creo que los evangelistas de Go suelen omitir, y que es importante decir con claridad. Para tareas de datos, machine learning y análisis, Python no tiene rival y Go no es una alternativa viable.
pandas, numpy y el ecosistema científico
import pandas as pd
df = pd.read_csv("ventas.csv")
# En una línea: agrupar, agregar, filtrar y ordenar
resumen = (
df.groupby(["region", "producto"])
.agg(total=("importe", "sum"), pedidos=("id", "count"))
.query("total > 10000")
.sort_values("total", ascending=False)
)Intentar hacer esto en Go requiere implementar la agrupación manualmente, escribir las funciones de agregación, gestionar los tipos de cada columna. Es perfectamente posible, pero el código será 10 veces más largo y no aportará un rendimiento significativamente mejor porque pandas y numpy usan C y Fortran bajo el capó. Las operaciones vectorizadas de numpy no están limitadas por el GIL. Cuando usas df.groupby().sum(), el trabajo pesado lo hace código C optimizado, no Python.
Machine learning y IA
No existe un equivalente en Go de scikit-learn, PyTorch, TensorFlow, Hugging Face o cualquier framework de ML maduro. Hay proyectos como gonum para álgebra lineal y gorgonia para redes neuronales, pero están a años luz del ecosistema de Python. Si tu tarea pesada implica entrenar modelos, hacer inferencia o procesar datos para ML, la conversación empieza y termina en Python.
ETL con transformaciones complejas
Para ETL donde la transformación requiere lógica de negocio compleja, joins entre datasets, limpieza de datos con reglas específicas del dominio, Python con pandas (o polars, si necesitas más rendimiento) es más productivo. El tiempo que ahorras en ejecución con Go lo pierdes con creces en tiempo de desarrollo y mantenimiento.
La regla general: si el cuello de botella es la computación vectorizada o el ecosistema de librerías, quédate en Python. Si el cuello de botella es la concurrencia, el I/O o el despliegue, considera Go.
El enfoque híbrido: lo mejor de ambos mundos
En la práctica, y esto es algo que me ha costado aceptar porque uno siempre quiere un stack limpio, la mejor solución suele ser combinar ambos lenguajes. No hace falta elegir uno u otro para todo.
Patrón 1: orquestación en Python, workers en Go
# orchestrator.py
import subprocess
import json
def run_go_worker(input_file, output_file):
"""Llama al binario Go para el trabajo pesado."""
result = subprocess.run(
["./go-processor", "--input", input_file, "--output", output_file],
capture_output=True, text=True
)
if result.returncode != 0:
raise RuntimeError(f"Worker falló: {result.stderr}")
return json.loads(result.stdout)
# Python se encarga de la lógica de orquestación
files = get_pending_files() # Consulta a BD, S3, etc.
for f in files:
stats = run_go_worker(f.input_path, f.output_path)
update_status(f.id, stats) # Actualizar BD con resultados
notify_if_needed(f, stats) # Enviar Slack, email, etc.Python hace lo que mejor sabe hacer: orquestar, integrar con servicios, aplicar lógica de negocio. Go hace lo que mejor sabe hacer: procesar datos rápidamente con paralelismo real.
Patrón 2: API en Go, análisis en Python
Si tienes un servicio que recibe datos en tiempo real y necesita procesarlos con baja latencia, la API puede estar en Go. Pero cuando necesitas analizar los datos históricos, generar reportes o entrenar modelos, esa parte vive en Python.
[Clientes] → [API Go: recepción + validación + almacenamiento]
↓
[Base de datos]
↓
[Scripts Python: análisis + reportes + ML]Patrón 3: prototipar en Python, reescribir el cuello de botella en Go
Este es mi patrón favorito, y probablemente el que más valor me ha dado. Escribo el script completo en Python primero. Lo ejecuto, lo mido, identifico dónde está el cuello de botella real (no el que imagino, el real). Si ese cuello de botella es algo que Go resuelve bien (concurrencia, I/O masivo, parseo pesado), lo reescribo en Go. Si no, lo dejo en Python y optimizo allí.
La clave es medir antes de reescribir. Y siendo honestos, he visto equipos (y me incluyo) reescribir un sistema completo en Go porque “Python es lento” y descubrir que el cuello de botella era una consulta SQL mal optimizada que tardaba lo mismo en cualquier lenguaje.
La historia del despliegue: pip+venv+Docker vs binario
Este es un punto que, por experiencia, mucha gente subestima hasta que lo sufre. Pero para tareas pesadas que se ejecutan en batch, el despliegue importa mucho.
El camino de Python
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "process_batch.py"]# docker-compose.yml o definición de tarea en ECS/K8s
services:
batch-processor:
build: .
volumes:
- ./data:/data
environment:
- DB_URL=postgresql://...
- S3_BUCKET=my-bucketFunciona. Pero:
- La imagen pesa 300-800 MB dependiendo de las dependencias.
- El build tarda minutos si hay dependencias con compilación nativa.
- Cada vez que actualizas una dependencia, rebuilds las capas.
- Si necesitas ejecutarlo fuera de Docker (en un servidor bare-metal, en un cron de una máquina de desarrollo), tienes que gestionar el entorno Python.
El camino de Go
# Makefile
build:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/processor ./cmd/processor
deploy:
scp bin/processor servidor:/opt/batch/processor
ssh servidor "systemctl restart batch-processor"O si prefieres Docker, el multi-stage build es mínimo:
FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go download
COPY . .
RUN CGO_ENABLED=0 go build -o /processor ./cmd/processor
FROM scratch
COPY --from=builder /processor /processor
ENTRYPOINT ["/processor"]Imagen final: menos de 15 MB. Sin sistema operativo, sin runtime, sin dependencias. El binario es todo lo que necesitas.
Para un proceso batch que necesitas ejecutar en múltiples servidores, la diferencia es enorme. Con Go, copias un fichero y listo. Con Python, necesitas replicar el entorno en cada máquina o usar Docker en todas partes.
Marco de decisión: cuándo reescribir y cuándo no
Después de haber hecho esta transición en varios proyectos, he ido desarrollando un criterio que intenta ser pragmático. No pretendo que sea una regla universal, pero funciona como punto de partida.
Reescribe en Go cuando
- El cuello de botella es la concurrencia de CPU. Tu script necesita hacer trabajo paralelo real (parsear, comprimir, transformar) en múltiples cores. El GIL de Python lo impide y
multiprocessingtiene demasiado overhead. - El cuello de botella es I/O masivo con procesamiento. Necesitas hacer miles de peticiones HTTP, leer cientos de ficheros o procesar streams de datos, y además hacer trabajo significativo con cada resultado.
asynciomaneja el I/O bien, pero el procesamiento sigue siendo secuencial. - El despliegue es un dolor. Necesitas distribuir la herramienta a múltiples servidores, ejecutarla en entornos heterogéneos o minimizar la huella en producción. Un binario estático simplifica todo.
- El consumo de memoria es un problema. Tu script Python necesita instancias grandes (y caras) solo por el overhead del intérprete y las estructuras de datos. Go usa una fracción de la memoria para el mismo trabajo.
- La latencia importa. Si es un servicio que debe responder en milisegundos, Go tiene una ventaja clara sobre Python por su tiempo de arranque instantáneo y su rendimiento predecible.
No reescribas cuando
- Usas pandas, numpy o cualquier librería de computación vectorizada. Estas librerías ya están optimizadas en C/Fortran. No vas a ganar rendimiento significativo reescribiendo en Go.
- La lógica de negocio es compleja y cambia frecuentemente. Python es más rápido para iterar. Si el script cambia cada semana, la velocidad de desarrollo importa más que la velocidad de ejecución.
- El equipo no conoce Go. Y esto es algo que a veces se pasa por alto: un script de Python que todo el equipo puede mantener es mejor que un script de Go que solo una persona entiende. La deuda técnica de un lenguaje que nadie domina es peor que unos minutos más de ejecución.
- El cuello de botella no es el código. Si tu script tarda 10 minutos pero 9 son espera de red o consultas a base de datos, reescribir en Go te ahorra un minuto. No merece la pena.
- El script es simple y se ejecuta poco. Un cron que se ejecuta una vez al día y tarda 5 minutos en Python no necesita optimización. El coste de reescritura nunca se amortiza.
La pregunta que siempre deberías hacerte
Si reescribo esto en Go, cuánto tiempo de computación ahorro al mes y cuánto cuesta ese tiempo versus las horas de desarrollo de la reescritura?
Si el ahorro mensual no cubre el coste de la reescritura en menos de 3-6 meses, probablemente no merece la pena. A menos que haya otros factores (fiabilidad, despliegue, mantenibilidad) que inclinen la balanza.
Lo que me ha enseñado mover tareas pesadas de Python a Go
Creo que Go y Python no compiten, aunque a veces se presente así. Resuelven problemas diferentes de formas diferentes. La comparación general entre Go y Python cubre las diferencias de filosofía y casos de uso amplios. Este artículo se centra en el caso concreto de tareas pesadas, donde la decisión tiene impacto directo en costes e infraestructura.
Después de mover varias tareas pesadas de Python a Go, la imagen que me queda es, creo, bastante clara. Aunque reconozco que cada caso tiene sus matices. Para scrapers y crawlers, Go gana de forma evidente: concurrencia real, parseo paralelo y un binario desplegable sin dependencias. Para procesamiento batch de ficheros, Go compensa cuando el volumen es alto y el trabajo por fila es significativo, pero si es simple transformación, Python con polars o DuckDB sigue siendo suficiente. Las herramientas CLI para distribución son otro caso donde el binario estático de Go es difícil de superar.
En cambio, para ETL con lógica de negocio compleja, Python sigue siendo mi elección. La productividad de pandas y la flexibilidad del lenguaje compensan la diferencia de rendimiento. Y para pipelines de ML o data science, no hay discusión: el ecosistema de Python no tiene rival en Go.
No reescribas todo en Go porque los benchmarks dicen que es más rápido. Mide tu caso concreto, identifica el cuello de botella real y toma la decisión basándote en datos, no en hype. No porque Go no sea una buena herramienta. Sino porque la decisión de reescribir es cara, y la migración incremental casi siempre funciona mejor que la reescritura completa. Empieza por el componente más crítico, no por el sistema entero.


