Azure Functions in F# - What you need to know to build working .NET Isolated F# Function Apps
FP and serverless are a match made in heaven, but not everything about this pairing is sunshine and rainbows
Disclaimer: This article is written for building .NET8 Isolated Azure Functions, so if you’re reading this from the future, hi, this might be outdated! Sometimes I struggle with existing articles because they don’t clarify what version they are for, so here is that warning.
Ever since I first stumbled onto Azure Functions, I thought it was too good to be true. Free hosting for my hobby projects and I don’t even have to manage any servers or anything? Sign me up! Unfortunately, managed systems like this work because decisions get made for you, and sometimes those decisions can be really frustrating to work around. Pile onto that a comparatively obscure language which itself has limited community usage documentation and unless you really know what you’re doing, you aren’t going to be having a good time.
So here I am, writing the article I wish existed for me months ago.
Lives for C#, Tolerates F#
Herein lies the biggest challenge with building Azure Functions in F#. Like is so common, the entire process of building Azure Functions in .NET is tailored to the C# developer, enforcing C# practices and C# principles. Some of these things you aren’t going to get around, so be prepared for your F# code to feel like a reskinned C#. Things that may appear like they should work fine don’t, so some things will boil down to it is what it is.
This article assumes you are already familiar with Azure Functions and concepts like triggers, bindings, the core tools, etc. If you have any questions, feel free to drop them below, but without further ado, let’s get into it.
Picky Structuring
When considering this is all built specifically with C# in mind, this one makes sense. But it is something you need to consider. I don’t know the ins and outs of how the functions get picked up, but from my testing, here are some examples of supported and unsupported ways of structuring your functions:
Namespace.Class.Method() ✅
Module.Function() ✅
Namespace.Module.Function() ✅
Class.Method() ❌
ModuleA.MobuleB.Function() ❌
So, for some examples of this in action, the following formats are safe for use:
module Module
open Microsoft.Azure.Functions.Worker
open Microsoft.Azure.Functions.Worker.Http
open System.Net
[<Function "MyFunction">]
let func([<HttpTrigger(AuthorizationLevel.Anonymous, "get")>] req: HttpRequestData) =
req.CreateResponse HttpStatusCode.NoContent
module Namespace.Module
open Microsoft.Azure.Functions.Worker
open Microsoft.Azure.Functions.Worker.Http
open System.Net
[<Function "MyFunction">]
let func([<HttpTrigger(AuthorizationLevel.Anonymous, "get")>] req: HttpRequestData) =
req.CreateResponse HttpStatusCode.NoContent
namespace Namespace
open Microsoft.Azure.Functions.Worker
open Microsoft.Azure.Functions.Worker.Http
open System.Net
type Class() = // Importantly, this allows you to use DI here
[<Function "MyFunction">]
member _.Func([<HttpTrigger(AuthorizationLevel.Anonymous, "get")>] req: HttpRequestData) =
req.CreateResponse HttpStatusCode.NoContent
For trivial projects, the first option can be placed directly above your host builder code, so this one works for those basic single file function apps you want to whip up quickly. It would be unfair of me to mention this and not give an example, as God knows how easy or hard that would be to actually find, so here is that:
module MySimpleProject
open Microsoft.Azure.Functions.Worker
open Microsoft.Azure.Functions.Worker.Http
open Microsoft.Extensions.Hosting
open System.Net
[<Function "MyFunction">]
let func([<HttpTrigger(AuthorizationLevel.Anonymous, "get")>] req: HttpRequestData) =
req.CreateResponse HttpStatusCode.NoContent
HostBuilder()
|> _.ConfigureFunctionsWorkerDefaults()
|> _.Build()
|> _.Run()
Async Must Be Wrapped
Functions expect their code to be either synchronous or return a task. Because of this, if you intend on using async, you will need to use Async.StartAsTask
if you want your functions to be properly picked up.
[<Function "Async">]
let func ([<HttpTrigger(AuthorizationLevel.Anonymous, "get")>] req: HttpRequestData) =
async {
do! asyncSomething()
return req.CreateResponse HttpStatusCode.NoContent
}
|> Async.StartAsTask
Task expressions work without anything extra necessary. FsToolkit.ErrorHandling
can be used fairly easily with task result and async result expressions too, but you will need your results to be flattened to a Task<HttpResponseData>
still.
No Currying Allowed
Once again, this one shouldn’t come as much of a surprise, but you cannot use currying in functions that are triggers. Of course, this is easy to get around with tuples.
[<Function "TupleFunction">]
let func (
[<HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "users/{userId:guid}")>] req: HttpRequestData,
userId: Guid
) =
printfn "Hello %A" userId
req.CreateResponse HttpStatusCode.NoContent
The Woes of JSON Serialization
This topic deserves an article of its own, but the built-in JSON serialization does not behave nicely with F# types. While simpler POCOs can work, if you want to be defining your DTOs as records and use things like discriminated unions with a neat serialization approach, you’re going to need to do some of this manually. My current favourite approach to this is Thoth.Json
which allowed me to successfully create serializers for the entire Discord API, something I spent countless hours unsuccessfully trying to do with System.Text.Json
itself.
Read more: Thoth.Json - Introduction
Reading the body of the request to a string then deserializing has been the most pain-free way I’ve found for getting the request body. If you have experience in this issue then please reach out as I’m very interested in this subject, but I do intend of taking a deep dive into this in the near future as well.
Project Setup Quirks
It’s critical that you ensure you are using the correct packages for a .NET Isolated project. The packages you use should follow the format Microsoft.Azure.Functions.Worker.*, not the in-process ones in Microsoft.Azure.WebJobs.*. There are some unusual cases like in the new OpenApi extension package it still uses the WebJobs namespacing, but ensuring the correct packages are referenced is essential for if you want your app to actually work (which it would be strange for you not to).
For reference, this is the project .fsproj
file for a simple app (with up-to-date packages as of 24/06/2025):
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<Compile Include="Program.fs" />
<None Include="local.settings.json" CopyToOutputDirectory="Always" />
<None Include="host.json" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.0.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.5" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="FSharp.Core" Version="9.0.300" />
</ItemGroup>
</Project>
One important thing to note is that because this is an article about .NET Isolated projects, this needs to be reflected in the local.settings.json
.
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"
}
}
If you use a tool to generate your initial project structure, make sure these match, as much of this tooling is still built for in-process function apps, which will be going out of support in the future.
Lastly, you’ll notice this log pop up every time you run your function app:
Csproj not found in C:\file\to\your\project\bin\Debug\net8.0 directory tree. Skipping user secrets file configuration.
Don’t worry! This is actually incorrect, and your secrets will be configured correctly. Any secrets in your local.settings.json
(or set as environment variables on the deployed resource) will be automatically pulled in and accessible from your .ConfigureFunctionsWorkerDefaults()
call.
My Function Works! Now What?
The good news is that once you have your function setup as you like it with all your bindings, dependencies, etc. you’re pretty much free to do as you wish! How you structure your code from this point is entirely up to you. I like to treat functions as individual methods on a controller and call into separate application logic to keep this presentation layer solely dedicated to presentation layer logic, but nothing stops you from using other binding extensions and building things in a more minimal manner.
Ultimately, much of the pain that comes from Azure Functions is mostly from wrangling these hard requirements that get set for us as developers by our own tools. Once we’ve reached this point, the sky is the limit.
Because F# isn’t as popular language, it can be a challenge to find content on these more niche topics. I’ve spent more time that I would like trying to find articles explaining this stack, as notably, much of what does exist is written for the older in-process model, which doesn’t really apply for the most part. Hopefully you’ve found this helpful, and like I’ve said before, I’m happy to answer and questions that are sent my way! If you found this article helpful, please consider subscribing to my Substack as this is my first of what I hope to be many articles on these sorts of topics!