Convex query vs mutation vs action: differences, use cases, and when to use each

2 min readApril 29, 2026#convex#backend#typescript

Convex: query, mutation, action — the real difference

It took me a while to really get this. Not the concept — the concept is simple. But why the rules exist.

Here's how I think about it now.

A query is a window. It looks. It doesn't touch anything. You can open it 50 times at once, no problem. And Convex updates it automatically when the data changes — that's the reactive part.

A mutation is a transaction. It writes to the database. All or nothing. If something crashes halfway through, nothing gets saved. Safe by design.

An action is the outside world. It can call an external API — Moneroo, Resend, whatever. In exchange: no direct access to the database.


The rule that follows from this, and one I wish I'd read on day one:

Never call an external API inside a mutation.

If it crashes midway — network drop, timeout — you end up with an order created in the database but no payment initiated. Or the reverse.

The right pattern: mutation → action → mutation.

// The mutation creates the order and delegates the rest
export const createOrder = mutation({
  handler: async (ctx, args) => {
    const orderId = await ctx.db.insert("orders", { ...args, status: "pending" });
    await ctx.scheduler.runAfter(0, internal.payments.initiate, { orderId });
    return orderId;
  }
});
 
// The action talks to Moneroo, then mutates again
export const initiate = internalAction({
  handler: async (ctx, { orderId }) => {
    const link = await moneroo.payments.initialize({ ... });
    await ctx.runMutation(internal.orders.setPaymentLink, { orderId, link });
  }
});

Mutation creates. Action calls out. Mutation confirms. Each step does exactly one thing.

It's more code. But every failure becomes manageable — you know exactly where it broke.