Two shapes of bounded work

If a piece of code has a clear start and a clear end - a queue message to process, a row to import, a report to generate - you have two natural homes for it on Azure. Azure Functions and Azure Container Apps Jobs both run finite work, both scale to zero when nothing is happening, and both pay only for what they use. The trick is that they sit in different places on the spectrum between "small handler, big platform" and "real container, thin platform".

Same idea. Different unit of deployment.
AZURE FUNCTIONS

A handler

You write a function in a supported language - C#, JavaScript, Python, Java, PowerShell, TypeScript. The runtime wraps it, hosts it, binds it to inputs and outputs, and calls it when a trigger fires.

Optimised for short, per-event work: sub-second to a few minutes, called many times, often with chatty bindings to other Azure services.

Unit: a function, hosted by Azure's runtime.
CONTAINER APP JOB

A container

You write whatever you want, in whatever stack you want, and package it as a container image. The platform starts the container when a trigger fires and waits for it to exit.

Optimised for longer, container-shaped tasks: minutes to hours, run-to-completion, with the same image and tooling you already use for services.

Unit: a container, run by the platform.

That framing is the whole comparison in miniature. Everything below is a consequence of "Microsoft hosts a function for you" vs "you hand the platform a container and it runs it".

Container-shaped vs function-shaped

The first question is whether the work you want to run actually fits the function programming model. Functions is opinionated about a few things, and those opinions are what make the runtime feel light.

A Function is a function: a piece of code with a fixed signature, called by the runtime with inputs already bound and a context object for outputs. You don't pick the process model, the listener, or the lifecycle - the host owns all of that. In return you get triggers and bindings as a single declarative layer: "this function is called for every Service Bus message; the message lands as the first argument; the return value gets written to a Cosmos DB container".

A Container App Job is a container: an image you build, with whatever process tree, libraries, and language inside it you choose. The platform's contract is much smaller - start the container when the trigger fires, wait for it to exit, collect its logs - and your code does the rest. There are no input bindings; the container reads the trigger payload (or polls the queue itself, in some shapes) and writes outputs however it likes.

The trade-off is a familiar one. Functions gives you more runtime for less code, at the cost of fitting the model. Jobs gives you more code for less runtime, at the cost of having to write the boilerplate the bindings would have written for you. Neither is "better" - they target different shapes of work.

Triggers and how they arrive

Both services support a similar surface of triggers - HTTP, queues, schedules, event streams - but the way the trigger reaches your code is different.

A Function trigger is delivered to the handler. The runtime picks up the message, calls your function with the payload already deserialized into the language-native type you declared, and tracks the success or failure to acknowledge the message back to the source. The handler signature is the contract.

A Container App Job trigger starts a container. The platform decides when an execution begins - schedule, KEDA scaler, or manual start - and your container is responsible for fetching the actual work (popping the queue message, querying the database, reading the parameters), processing it, and exiting. The trigger fires the start; what to do with the work is yours.

That distinction matters for two practical things. Bindings: Functions has them, Jobs does not. Per-message error handling: Functions tracks it automatically, Jobs leaves it to your code. If you want a runtime that turns "a Service Bus message arrived" into "this method runs with the typed message, and is retried if it throws", you want Functions. If you want a container that wakes up, drains a batch, and exits with a single status code, you want a Job.

Seconds, minutes, hours

The single most important practical limit is how long the work runs. Azure Functions has a default 5-minute per-invocation timeout on the Consumption plan, configurable up to 10 minutes; the Premium and Dedicated plans allow up to 60 minutes (and effectively unlimited on Dedicated). Container App Jobs have a per-replica replicaTimeout with a much higher ceiling - multiple hours - and the platform treats long-running execution as a first-class case rather than a corner of the documentation.

Axis
Azure Functions
Container App Job
Typical duration
Milliseconds to a few minutes.
Minutes to hours.
Default timeout
5 minutes on Consumption (max 10).
Set per job via replicaTimeout in seconds.
Hard ceiling
~60 minutes on Premium / Dedicated.
Hours-scale; suited to long-running batch work.
Best when the work is
Sub-second to low-minute, high-frequency, per-event.
Multi-minute to multi-hour, bounded, per-batch.

The rule of thumb most teams settle on: if the work routinely finishes under a few minutes, Functions is the natural home. If it routinely takes tens of minutes or more, you want a Job. The grey zone in the middle - 2 to 10 minutes - is where the other factors below decide it.

Cold start and scaling

Both services scale to zero, and both pay a cold-start cost the first time work arrives after a quiet period. The shape of that cost is different.

An idle Function on Consumption takes hundreds of milliseconds to a couple of seconds to wake up. The host is already running; your function is loaded into a worker process. For sub-second user-facing requests this is sometimes too much - which is why Premium and Dedicated plans exist, with always-ready instances and pre-warming.

An idle Container App Job takes seconds to tens of seconds to start an execution from zero. The platform has to provision a replica, pull the image (if it isn't cached on the node), and let the container boot. For minutes-or-hours work this is a rounding error. For sub-second work it would be unacceptable - which is part of why Jobs is not the right tool for sub-second work.

Both services scale up by adding more workers when there is more work. Functions adds function instances; Jobs adds job executions. The granularity is different - one is "one more invocation in parallel", the other is "one more container in parallel" - but the autoscaling story is similar in shape. For the deeper take on how each one actually scales, see How Functions and Jobs scale.

What you actually pay for

Both services bill on usage, but the unit they bill in is different - and so is the line between "lots of small invocations" and "fewer, longer runs".

Axis
Azure Functions
Container App Job
Billing unit
Executions + GB-seconds (Consumption). Per-instance on Premium / Dedicated.
vCPU-seconds + GiB-seconds of replica runtime.
Free grant
1M executions + 400,000 GB-seconds per month on Consumption.
Monthly free vCPU-seconds and GiB-seconds per environment.
Sweet spot
Many short invocations - the per-execution overhead disappears against high volume.
Fewer, longer runs - the per-execution overhead is absorbed by minutes of work.
Where it gets expensive
Long-running invocations on Consumption (you pay for every second the host is busy).
Very high-frequency, very short tasks (cold start dominates each execution).

The pattern: Functions is cheap at many-and-short, expensive at few-and-long. Jobs is the inverse - cheap at few-and-long, expensive at many-and-short. The crossover is somewhere in the low minutes, and it shifts based on how chatty your bindings are and how big your container image is.

A four-question decision tree

Walk these in order. If all four agree, the choice is easy; if they disagree, question 1 is usually the tiebreaker.

1. How long does one run actually take?
Seconds · Functions

Sub-second to a few minutes, called many times. The runtime overhead is small, the per-execution billing earns its keep. Functions is built for this shape.

Many minutes · Job

Tens of minutes to hours, called less often. The Functions timeout is now your enemy and the container cold-start is invisible. Container App Job.

2. Does the workload fit a function signature?
Yes · Functions

A single entry point, typed inputs and outputs, idiomatic in one of the supported languages. Bindings reduce real boilerplate. Functions.

No · Job

Multiple processes, native dependencies, an exotic language, a CLI tool, or a long chain of steps inside one image. Wrap it in a container. Container App Job.

3. Are you already shipping containers?
Yes · Job

If the rest of your stack already deploys to Container Apps, your tooling, identity, networking, and observability are already there. A Job slots in beside the services. Container App Job.

No, just code · Functions

If you don't want to learn a container toolchain to run a periodic task, Functions has less surface area to operate. The runtime is the platform. Functions.

4. How often does the trigger fire?
Constantly · Functions

Many invocations per second is the price model Functions is designed around. Per-execution overhead is amortised against volume. Functions.

A few times a day · Job

Sparse, scheduled, or bursty work. The per-execution start-up is rare and the bill is dominated by useful seconds. Container App Job.

If the answers conflict, the duration question almost always wins. A 2-hour daily report does not become a Function just because you'd prefer to write it in Python with bindings - it'll time out. A 50-millisecond webhook does not become a Job just because the rest of the stack is on Container Apps - the cold start dominates.

When you reach for both

The cleanest stacks tend to use them together, each for what it is good at.

A common shape: a fleet of Functions handle the small, high-frequency things - webhooks, per-message processing, lightweight HTTP endpoints, fan-out of work onto a queue. A few Container App Jobs handle the heavier, longer things on the other side of that queue - the actual data import, the nightly report, the multi-hour migration. The Function is the doorman; the Job is the back room.

You don't have to pick one tool for the whole platform. Pick Functions for the workloads that fit the model, Jobs for the workloads that don't, and let each one do what it is built for. Most teams find that mix lands in a comfortable place - and it leaves Container Apps for the long-running services that are neither.

UNIT

Handler or container

Functions ships a method into a hosted runtime. Jobs ships a container into a platform that runs it.

TIME

Minutes or hours

Functions caps in the minutes. Jobs runs in the hours. The duration of one run is the single biggest deciding factor.

SHAPE

Bindings or freedom

Functions trades flexibility for bindings and a tiny runtime. Jobs trades bindings for any image, any language, any stack.

PAIR

Use them together

Functions for small and frequent. Jobs for big and rare. The mix is the answer for most stacks, not "all of one".