Pasar de JavaScript a Python se siente como mudarse a una ciudad con las mismas señales de tráfico pero con leyes de tránsito diferentes. La sintaxis parece amigable y familiar. Ves async y await ahí mismo en la gramática, así que asumes que el modelo mental se traslada sin problemas. No es así. Un patrón habitual de JavaScript hunde silenciosamente tu rendimiento en Python sin provocar un error, sin registrar un fallo y sin aparecer en una revisión de código rápida.

Cómo JavaScript te enseña a iniciar y olvidar

En JavaScript, la llamada a una función async es eager. En el momento en que la invocas, el motor crea una Promise y el trabajo comienza de inmediato. El event loop ya está en marcha. Es por eso que los desarrolladores de JavaScript escriben código de forma natural así:

const userPromise = fetchUser(id);
const ordersPromise = fetchOrders(id);
const user = await userPromise;
const orders = await ordersPromise;

Ambas solicitudes de red están en curso antes de llegar a cualquier await. El primer await suspende la función actual hasta que fetchUser se resuelve, pero fetchOrders ha estado trabajando en segundo plano desde la línea anterior. Para cuando necesites la variable orders, es posible que la segunda solicitud ya haya terminado. Este patrón se siente tan natural en JavaScript que muchos desarrolladores ni siquiera lo consideran un truco de concurrencia. Es simplemente cómo funciona async.

La sorpresa de Python: una corrutina fría

Python utiliza un contrato diferente. Cuando llamas a una función async def en Python, no inicias ningún trabajo. Recibes un objeto corrutina (coroutine). Piénsalo como una receta escrita en un papel. Los ingredientes están listados, los pasos están claros, pero nada está en el horno. Hasta que algo impulse explícitamente esa corrutina a través del event loop, permanece inerte.

Aquí está la trampa. Un ingeniero de JavaScript que necesite un usuario y sus pedidos podría escribir esto en Python:

user_coro = fetch_user(id)
orders_coro = fetch_orders(id)
user = await user_coro
orders = await orders_coro

Parece concurrente. Huele a concurrente. Es totalmente secuencial.

La primera línea asigna una corrutina inactiva a user_coro. La segunda línea asigna otra corrutina inactiva a orders_coro. Cuando la ejecución llega a await user_coro, Python finalmente inicia la primera tarea y la ejecuta hasta completarla. Solo después de que fetch_user termina, el intérprete llega a await orders_coro e inicia la segunda tarea. Tu tiempo total de ejecución es la suma de ambas operaciones de E/S (I/O), no la de la más larga. No las ejecutaste en paralelo. Las ejecutaste una tras otra con pasos adicionales.

Por qué este error es invisible

Este es el tipo de regresión de rendimiento que sobrevive durante meses. El código es Python válido. Pasa los comprobadores de tipos. Devuelve los resultados correctos. Simplemente se ejecuta a la mitad de velocidad, o peor. Debido a que no hay un rastreo de pila (stack trace) ni una advertencia, los equipos de ingeniería suelen buscar en todas partes antes. Añaden cachés de Redis, mejoran los niveles de la base de datos o cambian las regiones de alojamiento. El verdadero culpable es un sutil desajuste en las expectativas sobre lo que await hace realmente.

Tres formas de hacer que Python ejecute cosas de forma concurrente realmente

Para solucionar esto, debes decirle al event loop de Python que programe el trabajo de inmediato. Necesitas algo más activo que una corrutina pura. Necesitas una Task.

1. asyncio.create_task

La traducción más directa del patrón de JavaScript es envolver tu corrutina en una Task. Una Task se programa en el event loop tan pronto como la creas. Es el equivalente más cercano en Python a una Promise de JavaScript en movimiento.

user_task = asyncio.create_task(fetch_user(id))
orders_task = asyncio.create_task(fetch_orders(id))

user = await user_task
orders = await_orders_task

Ahora tanto fetch_user como fetch_orders están en curso antes del primer await. Cuando llegas a await user_task, te detienes solo hasta que esa Task específica se completa, pero la otra Task sigue ejecutándose. Si fetch_orders termina primero, su resultado simplemente espera dentro de orders_task hasta que lo solicites.

Ten cuidado, sin embargo. Si creas una Task y nunca la esperas con await, Python emitirá un error sobre una tarea pendiente destruida. Aun así, debes recolectar tus resultados.

2. asyncio.gather

Si tienes varias corrutinas que deben terminar antes de continuar, asyncio.gather se encarga del código repetitivo (boilerplate) por ti. Programa cada corrutina como una Task internamente y las espera todas juntas.

user, orders = await asyncio.gather(fetch_user(id), fetch_orders(id))

Esto es conciso y legible. Destaca cuando las operaciones son independientes y quieres una sola línea que exprese "ejecuta todo esto y luego dame cada resultado". También preserva el orden de los argumentos en la lista o tupla devuelta, incluso si las tareas subyacentes se completan en un orden diferente.

3. asyncio.TaskGroup

Python 3.11 introduced TaskGroup, which brings structured concurrency to the standard library. Instead of creating tasks by hand, you use a context manager that ensures every spawned task finishes properly. If one task raises an exception, the others are cancelled automatically.

async with asyncio.TaskGroup() as tg:
    user_task = tg.create_task(fetch_user(id))
    orders_task = tg.create_task(fetch_orders(id))

user = user_task.result()
orders = orders_task.result()

This pattern is excellent for complex workflows. It removes the risk of orphaning a Task, and it groups the lifecycle of related operations under one logical umbrella. If your codebase runs on Python 3.11 or newer, this is often the cleanest architecture for fan-out concurrency.

The Mental Model: await Means "Run This Now"

The core lesson is linguistic. In JavaScript, you can read await as "meanwhile." You kick off work, do other things, and pause only when you need the value. In Python, await means "drive this coroutine to its next suspension point or completion." If the coroutine has not been scheduled yet, await is what schedules it. That is why you cannot start two raw coroutines and then await them later. You have not given the event loop anything to do in the meantime.

Think of Python coroutines like generator functions. Calling the generator does not iterate it. You need to loop over it, call next(), or pass it to a consumer. Async works the same way. asyncio.create_task is the consumer that says "put this on the event loop right now." The subsequent await simply waits for the finished signal.

One concrete habit that helps: whenever you assign an async function call to a variable without an await, ask yourself whether you have scheduled it. If the right-hand side is not wrapped in create_task, gather, or TaskGroup, it is not running. It is just a recipe sitting on the counter.

Takeaway

Python’s async runtime is powerful, but it demands explicit intent. The language does not start background work simply because you called a function. If you are coming from JavaScript, audit every place where you store a coroutine in a variable and await it later. Unless you promoted it to a Task first, you wrote sequential code dressed in async clothing. Start the work with a Task, then wait for the results. That is how you turn Python async from a silent bottleneck into a genuine concurrency tool.