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".
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.
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.
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.
replicaTimeout in seconds.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".
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.
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.
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.
A single entry point, typed inputs and outputs, idiomatic in one of the supported languages. Bindings reduce real boilerplate. Functions.
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.
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.
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.
Many invocations per second is the price model Functions is designed around. Per-execution overhead is amortised against volume. Functions.
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.
Handler or container
Functions ships a method into a hosted runtime. Jobs ships a container into a platform that runs it.
Minutes or hours
Functions caps in the minutes. Jobs runs in the hours. The duration of one run is the single biggest deciding factor.
Bindings or freedom
Functions trades flexibility for bindings and a tiny runtime. Jobs trades bindings for any image, any language, any stack.
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".
References
- What is an Azure Container App Job?stacknova · cloud · container-app-jobs
- Container App vs Container App Jobstacknova · cloud · app-vs-job
- Azure Functions overviewlearn.microsoft.com/azure/azure-functions/functions-overview
- Azure Functions scale and hosting planslearn.microsoft.com/azure/azure-functions/functions-scale
- Jobs in Azure Container Appslearn.microsoft.com/azure/container-apps/jobs
- Azure Functions pricingazure.microsoft.com/pricing/functions
- Container Apps pricingazure.microsoft.com/pricing/container-apps