Go per a tasques pesades: quan millora Python i quan no
Comparació pràctica de Go i Python en scripts pesats, concurrència, scraping, ETL i processos batch. Quan val la pena el canvi.

Vaig reescriure un scraper de Python en Go i va passar de 45 minuts a 3. Tres minuts. El scraper recorria unes 80.000 pàgines d’un catàleg públic, parseava l’HTML i guardava els resultats en un JSON per lots. En Python, amb requests i BeautifulSoup, funcionava bé però era lent. Les peticions es feien seqüencialment, el GIL limitava qualsevol intent de paral·lelisme real i l’ús de memòria creixia de forma constant. En Go, amb goroutines, net/http i un pool de workers, el mateix treball acabava en una fracció del temps amb un consum de memòria estable.
Però també vaig intentar reescriure un pipeline de dades que transformava CSVs amb pandas, aplicava neteja amb numpy i generava reportes amb matplotlib. I me’n vaig penedir. Vaig trigar tres vegades més a escriure-ho, el codi era el doble de llarg i el resultat no era significativament més ràpid perquè el coll d’ampolla estava en l’I/O de disc, no en la CPU.
I sent honestos, aquesta és la part que ningú t’explica quan llegeixes els benchmarks: Go no és millor que Python en tot. És millor en coses específiques. Crec que saber quan val la pena el canvi és el que separa una decisió tècnica bona d’una reescriptura que no aporta res.
On Python comença a patir
Python és un llenguatge extraordinari per a prototipat, automatització i data science. No tinc cap dubte d’això. Però té limitacions reals quan li demanes rendiment sostingut en tasques pesades.
El GIL: l’elefant a l’habitació
El Global Interpreter Lock (GIL) és el mecanisme que garanteix que només un fil executi bytecode de Python a la vegada. Això simplifica la gestió de memòria de l’intèrpret, però té una conseqüència directa: el multithreading en Python no aprofita múltiples cores per a treball CPU-bound.
import threading
import time
def cpu_heavy(n):
\"\"\"Simula treball 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ó seqüencial
start = time.time()
for _ in range(4):
cpu_heavy(50_000_000)
print(f\"Seqüencial: {time.time() - start:.2f}s\")Executa això i veuràs que la versió amb 4 threads tarda el mateix o més que la seqüencial. El GIL serialitza el treball CPU-bound. Pots usar multiprocessing, sí, però això té overhead de serialització entre processos i consumeix molta més memòria perquè cada procés copia l’intèrpret complet.
Per a I/O-bound (peticions HTTP, lectura de fitxers), asyncio i aiohttp són bones solucions. Però quan barreges I/O amb processament significatiu de les dades rebudes, el model de Python comença a cruixir.
Desplegament: l’infern de les dependències
Qualsevol que hagi desplegat scripts de Python en producció coneix el ritual:
- Crees un
venvo usespoetry/pipenv/uv. - Instal·les dependències. Algunes necessiten compiladors C perquè són wrappers de llibreries natives.
- T’assegures que la versió de Python del servidor coincideix amb la de desenvolupament.
- Ho empaques tot en un Docker per no tornar-te boig.
- La imatge de Docker pesa 800 MB perquè inclou l’intèrpret, les dependències i les llibreries del sistema.
Funciona, però té fricció. Per a un servei en producció amb CI/CD, és manejable. Però per a un script de processament batch que necessites executar en 10 servidors diferents, la situació canvia. Aquí és on comença a fer mal.
Consum de memòria
Python no és eficient amb la memòria. Un dict en Python consumeix significativament més memòria que una estructura equivalent en un llenguatge amb tipatge estàtic. Quan processes milions de registres en memòria, això importa. He vist scripts de processament batch en Python que necessitaven 8 GB de RAM per a un treball que en Go es feia amb 500 MB.
On Go marca la diferència
Go no és un llenguatge bonic. No té l’expressivitat de Python, ni l’ergonomia de Kotlin, ni la potència del sistema de tipus de Rust. No fingiré el contrari. Però per a certes tasques pesades, té avantatges tècnics reals que convé entendre.
Concurrència nativa amb goroutines
Les goroutines són fils lleugers gestionats pel runtime de Go. Són barates de crear (uns pocs KB d’stack inicial), el scheduler les distribueix entre els cores disponibles i la comunicació entre elles es fa amb channels. No hi ha GIL. No hi ha overhead de serialització entre processos. Simplement funcionen.
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ó seqüencial
start = time.Now()
for i := 0; i < 4; i++ {
cpuHeavy(50_000_000)
}
fmt.Printf(\"Seqüencial: %v\n\", time.Since(start))
}Aquí sí veuràs una diferència real. Les 4 goroutines s’executen en paral·lel en diferents cores. En una màquina de 4 cores, el temps amb goroutines serà aproximadament una quarta part del seqüencial. En Python, recorda, era el mateix.
Si véns de Python i vols entendre bé el model de concurrència en Go, allà tens una guia completa. Però el resum és: la concurrència en Go és un ciutadà de primera classe del llenguatge, no un pegat sobre un runtime que no va ser dissenyat per a això.
Binari únic: compila i executa
GOOS=linux GOARCH=amd64 go build -o scraper .
scp scraper servidor:/usr/local/bin/
ssh servidor \"scraper --config /etc/scraper.yaml\"Això és tot. Un binari estàtic que inclou tot el que necessita. Sense runtime, sense dependències, sense contenidors obligatoris. Funciona en qualsevol màquina amb el mateix OS i arquitectura. Per a eines de línia de comandos, scripts de processament batch i utilitats que necessites distribuir a múltiples servidors, això és un canvi de paradigma respecte a Python.
Eficiència de memòria
Go usa structs amb layout fix en memòria. No hi ha overhead de diccionaris, no hi ha boxing de tipus primitius, no hi ha recol·lector de brossa generacional com el de Python (Go usa un GC concurrent mark-and-sweep amb pauses molt baixes). Per a processar grans volums de dades en memòria, la diferència és significativa.
Comparació pràctica: scraping HTTP
Anem al cas que vaig mencionar al principi. Imagina que necessites scrapejar 10.000 URLs, parsear l’HTML i extreure dades específiques.
Python amb asyncio i 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\"Processats: {len(results)}\")
asyncio.run(main())Aquest codi funciona bé. asyncio és eficient per a I/O concurrent. Però hi ha matisos:
BeautifulSoupés síncron. El parseo de l’HTML bloqueja l’event loop. Amb 10.000 pàgines, aquell temps s’acumula.- Si el parseo és pesat (extreure múltiples elements, navegar el DOM), estàs CPU-bound dins d’un model async.
- El consum de memòria creix perquè
asyncio.gathermanté totes les coroutines i els seus resultats en memòria.
Go amb goroutines i 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)
}()
// Tancar results quan acabin els workers
go func() {
wg.Wait()
close(results)
}()
var collected []Result
for r := range results {
collected = append(collected, r)
}
fmt.Printf(\"Processats: %d en %v\n\", len(collected), time.Since(start))
}La diferència clau aquí no és només la velocitat de les peticions HTTP (tots dos poden fer 50 concurrents). És que el parseo de l’HTML en Go ocorre en paral·lel real dins de cada goroutine, mentre que en Python el parseo de BeautifulSoup serialitza el treball en l’event loop. Amb 10.000 pàgines, aquell parseo acumulat marca una diferència important.
En les meves proves reals amb un scraper de catàleg:
| Mètrica | Python (asyncio) | Go (worker pool) |
|---|---|---|
| Temps total | ~45 min | ~3 min |
| Memòria pic | ~1,2 GB | ~180 MB |
| CPU utilitzada | 1 core (GIL) | 4 cores |
| Mida desplegable | Docker ~800 MB | Binari ~12 MB |
Els números varien segons el cas, però la proporció és representativa. Si el teu scraper té una fase de parseo significativa, Go serà més ràpid perquè paral·lelitza de debò.
Comparació pràctica: processament de fitxers CSV/JSON
Un altre cas habitual: llegir un CSV de diversos milions de files, transformar les dades i escriure el resultat.
Python amb csv estàndard
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ó: netejar, convertir tipus, 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\"Processats: {len(results)} registres\")Go equivalent
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 índexs de columnes
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(\"Processat en %v\n\", time.Since(start))
}Per a un CSV de 5 milions de files, Go serà més ràpid (típicament 2-4x) i usarà menys memòria. Però la pregunta interessant és una altra: per a aquest cas, val la pena?
Si el teu script de processament CSV s’executa una vegada al dia com un cron job, i Python ho fa en 2 minuts, crec que no té sentit reescriure-ho en Go perquè trigui 40 segons. El codi de Python és més curt, més fàcil de modificar i qualsevol persona de l’equip amb coneixements de data ho entén.
Però la situació canvia si processes fitxers de 50 milions de files cada hora i el procés Python tarda 30 minuts i consumeix 12 GB de RAM. Aquí la conversió a Go comença a tenir sentit econòmic real: menys temps de còmput, instàncies més petites, menor cost d’infraestructura.
Quan Go NO ajuda: l’ecosistema de dades de Python
I aquí ve la part que crec que els evangelistes de Go solen ometre, i que és important dir amb claredat. Per a tasques de dades, machine learning i anàlisi, Python no té rival i Go no és una alternativa viable.
pandas, numpy i l’ecosistema científic
import pandas as pd
df = pd.read_csv(\"vendes.csv\")
# En una línia: agrupar, agregar, filtrar i ordenar
resum = (
df.groupby([\"region\", \"producte\"])
.agg(total=(\"import\", \"sum\"), comandes=(\"id\", \"count\"))
.query(\"total > 10000\")
.sort_values(\"total\", ascending=False)
)Intentar fer això en Go requereix implementar l’agrupació manualment, escriure les funcions d’agregació, gestionar els tipus de cada columna. És perfectament possible, però el codi serà 10 vegades més llarg i no aportarà un rendiment significativament millor perquè pandas i numpy usen C i Fortran sota el capó. Les operacions vectoritzades de numpy no estan limitades pel GIL. Quan uses df.groupby().sum(), el treball pesat el fa codi C optimitzat, no Python.
Machine learning i IA
No existeix un equivalent en Go de scikit-learn, PyTorch, TensorFlow, Hugging Face o qualsevol framework de ML madur. Hi ha projectes com gonum per a àlgebra lineal i gorgonia per a xarxes neuronals, però estan a anys llum de l’ecosistema de Python. Si la teva tasca pesada implica entrenar models, fer inferència o processar dades per a ML, la conversa comença i acaba en Python.
ETL amb transformacions complexes
Per a ETL on la transformació requereix lògica de negoci complexa, joins entre datasets, neteja de dades amb regles específiques del domini, Python amb pandas (o polars, si necessites més rendiment) és més productiu. El temps que estalvies en execució amb Go el perds de sobres en temps de desenvolupament i manteniment.
La regla general: si el coll d’ampolla és la computació vectoritzada o l’ecosistema de llibreries, queda’t en Python. Si el coll d’ampolla és la concurrència, l’I/O o el desplegament, considera Go.
L’enfocament híbrid: el millor dels dos mons
A la pràctica, i això és alguna cosa que m’ha costat acceptar perquè un sempre vol un stack net, la millor solució sol ser combinar tots dos llenguatges. No cal escollir-ne un o l’altre per a tot.
Patró 1: orquestració en Python, workers en Go
# orchestrator.py
import subprocess
import json
def run_go_worker(input_file, output_file):
\"\"\"Crida al binari Go per al treball pesat.\"\"\"
result = subprocess.run(
[\"./go-processor\", \"--input\", input_file, \"--output\", output_file],
capture_output=True, text=True
)
if result.returncode != 0:
raise RuntimeError(f\"Worker va fallar: {result.stderr}\")
return json.loads(result.stdout)
# Python s'encarrega de la lògica d'orquestració
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) # Actualitzar BD amb resultats
notify_if_needed(f, stats) # Enviar Slack, email, etc.Python fa el que millor sap fer: orquestrar, integrar amb serveis, aplicar lògica de negoci. Go fa el que millor sap fer: processar dades ràpidament amb paral·lelisme real.
Patró 2: API en Go, anàlisi en Python
Si tens un servei que rep dades en temps real i necessita processar-les amb baixa latència, la API pot estar en Go. Però quan necessites analitzar les dades històriques, generar reportes o entrenar models, aquella part viu en Python.
[Clients] → [API Go: recepció + validació + emmagatzematge]
↓
[Base de dades]
↓
[Scripts Python: anàlisi + reportes + ML]Patró 3: prototipeu en Python, reescriviu el coll d’ampolla en Go
Aquest és el meu patró favorit, i probablement el que més valor m’ha donat. Escric el script complet en Python primer. L’executo, el mesuro, identifico on és el coll d’ampolla real (no el que imagino, el real). Si aquell coll d’ampolla és alguna cosa que Go resol bé (concurrència, I/O massiu, parseo pesat), ho reescric en Go. Si no, ho deixo en Python i optimitzo allà.
La clau és mesurar abans de reescriure. I sent honestos, he vist equips (i m’incloc) reescriure un sistema complet en Go perquè “Python és lent” i descobrir que el coll d’ampolla era una consulta SQL mal optimitzada que trigava el mateix en qualsevol llenguatge.
La història del desplegament: pip+venv+Docker vs binari
Aquest és un punt que, per experiència, molta gent subestima fins que ho pateix. Però per a tasques pesades que s’executen en batch, el desplegament importa molt.
El camí 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ó de tasca en ECS/K8s
services:
batch-processor:
build: .
volumes:
- ./data:/data
environment:
- DB_URL=postgresql://...
- S3_BUCKET=my-bucketFunciona. Però:
- La imatge pesa 300-800 MB depenent de les dependències.
- El build tarda minuts si hi ha dependències amb compilació nativa.
- Cada vegada que actualitzes una dependència, reconstrueixes les capes.
- Si necessites executar-ho fora de Docker (en un servidor bare-metal, en un cron d’una màquina de desenvolupament), has de gestionar l’entorn Python.
El camí 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 prefereixes Docker, el multi-stage build és mínim:
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\"]Imatge final: menys de 15 MB. Sense sistema operatiu, sense runtime, sense dependències. El binari és tot el que necessites.
Per a un procés batch que necessites executar en múltiples servidors, la diferència és enorme. Amb Go, copies un fitxer i listo. Amb Python, necessites replicar l’entorn en cada màquina o usar Docker a tot arreu.
Marc de decisió: quan reescriure i quan no
Després d’haver fet aquesta transició en diversos projectes, he anat desenvolupant un criteri que intenta ser pragmàtic. No pretenc que sigui una regla universal, però funciona com a punt de partida.
Reescriu en Go quan
- El coll d’ampolla és la concurrència de CPU. El teu script necessita fer treball paral·lel real (parsear, comprimir, transformar) en múltiples cores. El GIL de Python ho impedeix i
multiprocessingté massa overhead. - El coll d’ampolla és I/O massiu amb processament. Necessites fer milers de peticions HTTP, llegir centenars de fitxers o processar streams de dades, i a més fer treball significatiu amb cada resultat.
asynciogestiona l’I/O bé, però el processament segueix sent seqüencial. - El desplegament és un dolor. Necessites distribuir l’eina a múltiples servidors, executar-la en entorns heterogenis o minimitzar l’empremta en producció. Un binari estàtic simplifica tot.
- El consum de memòria és un problema. El teu script Python necessita instàncies grans (i cares) només per l’overhead de l’intèrpret i les estructures de dades. Go usa una fracció de la memòria per al mateix treball.
- La latència importa. Si és un servei que ha de respondre en mil·lisegons, Go té un avantatge clar sobre Python pel seu temps d’arrencada instantani i el seu rendiment predictible.
No reescriguis quan
- Uses pandas, numpy o qualsevol llibreria de computació vectoritzada. Aquestes llibreries ja estan optimitzades en C/Fortran. No guanyaràs rendiment significatiu reescrivint en Go.
- La lògica de negoci és complexa i canvia freqüentment. Python és més ràpid per iterar. Si el script canvia cada setmana, la velocitat de desenvolupament importa més que la velocitat d’execució.
- L’equip no coneix Go. I això és alguna cosa que de vegades es passa per alt: un script de Python que tot l’equip pot mantenir és millor que un script de Go que només una persona entén. El deute tècnic d’un llenguatge que ningú domina és pitjor que uns minuts més d’execució.
- El coll d’ampolla no és el codi. Si el teu script tarda 10 minuts però 9 són espera de xarxa o consultes a base de dades, reescriure en Go t’estalvia un minut. No val la pena.
- El script és simple i s’executa poc. Un cron que s’executa una vegada al dia i tarda 5 minuts en Python no necessita optimització. El cost de reescriptura mai s’amortitza.
La pregunta que sempre t’hauries de fer
Si reescric això en Go, quant temps de computació estalvio al mes i quant costa aquell temps versus les hores de desenvolupament de la reescriptura?
Si l’estalvi mensual no cobreix el cost de la reescriptura en menys de 3-6 mesos, probablement no val la pena. Tret que hi hagi altres factors (fiabilitat, desplegament, mantenibilitat) que inclinin la balança.
El que m’ha ensenyat moure tasques pesades de Python a Go
Crec que Go i Python no competeixen, encara que de vegades es presenti així. Resolen problemes diferents de formes diferents. La comparació general entre Go i Python cobreix les diferències de filosofia i casos d’ús amplis. Aquest article se centra en el cas concret de tasques pesades, on la decisió té impacte directe en costos i infraestructura.
Després de moure diverses tasques pesades de Python a Go, la imatge que em queda és, crec, bastant clara. Encara que reconec que cada cas té els seus matisos. Per a scrapers i crawlers, Go guanya de forma evident: concurrència real, parseo paral·lel i un binari desplegable sense dependències. Per a processament batch de fitxers, Go compensa quan el volum és alt i el treball per fila és significatiu, però si és simple transformació, Python amb polars o DuckDB segueix sent suficient. Les eines CLI per a distribució són un altre cas on el binari estàtic de Go és difícil de superar.
En canvi, per a ETL amb lògica de negoci complexa, Python segueix sent la meva elecció. La productivitat de pandas i la flexibilitat del llenguatge compensen la diferència de rendiment. I per a pipelines de ML o data science, no hi ha discussió: l’ecosistema de Python no té rival en Go.
No reescriguis tot en Go perquè els benchmarks diuen que és més ràpid. Mesura el teu cas concret, identifica el coll d’ampolla real i pren la decisió basant-te en dades, no en hype. No perquè Go no sigui una bona eina. Sinó perquè la decisió de reescriure és cara, i la migració incremental gairebé sempre funciona millor que la reescriptura completa. Comença pel component més crític, no pel sistema sencer.


