Oh no, not another 'Is Rust better than Go?' article. Seriously, haven't we all had our fill of these comparisons by now? But before you sigh in exasperation, hear us out!
Many comparisons between Go and Rust emphasize their differences in syntax and the initial learning curve. However, ultimately, what matters is the ease of use for non-trivial projects.
Since we are a platform-as-a-service provider, we think that we can contribute the most by showing you how to build a small web service in both languages. We will use the same task and popular libraries for both languages to compare the solutions side-by-side so that you can make up your own mind and get a feel for what it's like to work in each ecosystem.
So, before you dismiss this as 'just another comparison', give it a read. There might be some details that other comparisons have missed before.
That old "Rust versus Go" debate
Rust vs Go is a topic that keeps popping up and there has been a lot written about it already. That is in part because developers are looking for information to help them decide which language to use for their next web project, and both languages get frequently mentioned in that context. We looked around, but there really is not much in-depth content on the topic out there, so developers are left to figure it out on their own and run the risk of dismissing an option too early due to misguided reasons.
Both communities often face misconceptions and biases. Some view Rust primarily as a systems programming language, questioning its suitability for web development. Meanwhile, others label Go as overly simplistic, doubting its capacity to handle intricate web applications. However, these are merely superficial judgments.
In reality, both languages are fine to be used for writing fast and reliable web services. However, their approaches are quite different, and it is hard to find a good comparison that tries to be fair to both.
This post is our attempt to give you an overview of the differences between Go and Rust with a focus on web development by building a non-trivial real-world application in both languages. We will go beyond the syntax and take a closer look at how the langauges handle typical web tasks like routing, middleware, templating, db access and more.
By the end of this post, you should have a good idea of which language is the right one for you.
Although we are aware of our own biases and preferences, we we will try to be as objective as possible and highlight the strengths and weaknesses of both languages.
Building a small web service
We will cover the following topics:
- Routing
- Templating
- Database access
- Deployment
We will leave out topics like client-side rendering or migrations, and focus on the server-side only.
The task
Picking a task that is representative for web development is not easy: On one hand, we want to keep it simple enough so that we can focus on the language features and libraries. On the other hand, we want to make sure that the task is not too simple so that we can show how to use the language features and libraries in a realistic setting.
We decided to build a weather forecast service. The user should be able to enter a city name and get the current weather forecast for that city. The service should also show a list of recently searched cities.
As we extend the service, we will add the following features:
- A simple UI to display the weather forecast
- A database to store recently searched cities
The Weather API
For the weather forecast, we will use the Open-Meteo API, because it is open source, easy to use, and offers a generous free tier for non-commercial use of up to 10,000 requests per day.
We will use these two API endpoints:
- The GeoCoding API to get the coordinates of a city.
- The Weather Forecast API to get the weather forecast for the given coordinates.
There are libraries for both Go (omgo) and Rust (openmeteo), which we would use in a production service. However, for the sake of comparison, we want to see what it takes to make a "raw" HTTP request in both languages and convert the response to an idiomatic data structure.
A Go web service
Choosing a web framework
Being originally created to simplify building web services, Go has a number of great web-related packages. If the standard library doesn't cover your needs, there are a number of popular third-party web frameworks like Gin, Echo, or Chi to choose from.
Which one to pick is a matter of personal preference. Some experienced Go developers prefer to use the standard library and add a routing library like Chi on top of it. Others prefer a more batteries-included approach and use a full-featured framework like Gin or Echo.
Both options are fine, but for the purpose of this comparison, we will choose Gin because it is one of the most popular frameworks and it supports all the features we need for our weather service.
Making HTTP requests
Let's start with a simple function that makes an HTTP request to the Open Meteo API and returns the response body as a string:
The function takes a city name as an argument and returns the coordinates
of the city as a LatLong
struct.
Note how we handle errors after each step: We check if the HTTP request was successful, if the response body could be decoded, and if the response contains any results. If any of these steps fails, we return an error and abort the function. So far, we just needed to use the standard library, which is great.
The defer
statement ensures that the response body is closed after the
function returns. This is a common pattern in Go to avoid resource leaks.
The compiler does not warn us in case we forget, so we need to be careful
here.
Error handling takes up a big part of the code. It is straightforward, but it can be tedious to write, and it can make the code harder to read. On the plus side, the error handling is easy to follow, and it is clear what happens in case of an error.
Since the API returns a JSON object with a list of results, we need to define a struct that matches that response:
The json
tags tell the JSON decoder how to map the JSON fields to the struct fields. Extra fields in the JSON response are ignored by default.
Let's define another function that takes our LatLong
struct and returns the weather forecast for that location:
For a start, let's call these two functions in order and print the result:
This will print the following output:
Nice! We got the weather forecast for London. Let's make this available as a web service.
Routing
Routing is one of the most basic tasks of a web framework. First, let's add gin to our project.
Then, let's replace our main()
function with a server and a route that takes a city name as a parameter and returns the weather forecast for that city.
Gin supports path parameters and query parameters.
Which one you want to use depends on your use case. In our case, we want to submit the city name from a form in the end, so we will use a query parameter.
In a separate terminal, we can start the server with go run .
and make a request to it:
And we get our weather forecast:
I like the log output and it's quite fast, too!
Templates
We got our endpoint, but raw JSON is not very useful to a normal user.
In a real-world application, we would probably serve the JSON response on an API
endpoint (say /api/v1/weather/:city
) and add a separate endpoint that returns
the HTML page. For the sake of simplicity, we will just return the HTML page
directly.
Let's add a simple HTML page that displays the weather forecast for a given city
as a table. We will use the html/template
package from the standard library to
render the HTML page.
First, let's add some structs for our view:
This is just a direct mapping of the relevant fields in the JSON response to a struct. There are tools like transform, which make conversion from JSON to Go structs easier. Take a look!
Next we define a function, which converts the raw JSON response from the weather
API into our new WeatherDisplay
struct:
Date handling is done with the built-in time
package.
To learn more about date handling in Go, check out this article.
We extend our route handler to render the HTML page:
Let's deal with the template next.
Create a template directory called views
and tell Gin about it:
Finally, we can create a template file weather.html
in the views
directory:
(Take a look at the Gin documentation for more details on how to use templates.)
With that, we have a working web service that returns the weather forecast for a given city as an HTML page!
Oh! Perhaps we also want to create an index page with an input field, which allows us to enter a city name and displays the weather forecast for that city.
Let's add a new route handler for the index page:
And a new template file index.html
:
Now we can start our web service and open http://localhost:8080 in our browser:

The weather forecast for London looks like this. It's not pretty, but... functional! (And it works without JavaScript and in terminal browsers!)

As an exercise, you can add some styling to the HTML page, but since we care more about the backend, we will leave it at that.
Database access
Our service fetches the latitude and longitude for a given city from an external API on every single request. That's probably fine in the beginning, but eventually we might want to cache the results in a database to avoid unnecessary API calls.
To do so, let's add a database to our web service. We will use PostgreSQL as our database and sqlx as the database driver.
First, we create a file named init.sql
, which will be used to initialize our database:
We store the latitude and longitude for a given city.
The SERIAL
type is a PostgreSQL auto-incrementing integer.
To make things fast, we will also add an index on the name
column.
It's probably easiest to use Docker or any of the cloud providers. At the end of the day, you just need a database URL, which you can pass to your web service as an environment variable.
We won't go into the details of setting up a database here, but a simple way to get a PostgreSQL database running with Docker locally is:
However once we have our database, we need to add the sqlx dependency to our go.mod
file:
We can now use the sqlx
package to connect to our database by using the connection string from the DATABASE_URL
environment variable:
And with that, we have a database connection!
Let's add a function to insert a city into our database.
We will use our LatLong
struct from earlier.
Let's rename our old getLatLong
function to fetchLatLong
and add a new getLatLong
function, which uses the database instead of the external API:
Here we directly pass the db
connection to our getLatLong
function.
In a real application, we should decouple the database access from the API logic, to make testing possible.
We would probably also use an in-memory-cache to avoid unnecessary database calls. This is just to compare database access in Go and Rust.
We need to update our handler:
With that, we have a working web service that stores the latitude and longitude for a given city in a database and fetches it from there on subsequent requests.
Middleware
The last bit is to add some middleware to our web service. We already got some nice logging for free from Gin.
Let's add a basic-auth middleware and protect our /stats
endpoint,
which we will use to print the last search queries.
That's it!
Pro-tip: you can also group routes together to apply authentication to multiple routes at once.
Here's the logic to fetch the last search queries from the database:
Now let's wire up our /stats
endpoint to print the last search queries:
Our stats.html
template is simple enough:
And with that, we have a working web service! Congratulations!
We have achieved the following:
- A web service that fetches the latitude and longitude for a given city from an external API
- Stores the latitude and longitude in a database
- Fetches the latitude and longitude from the database on subsequent requests
- Prints the last search queries on the
/stats
endpoint - Basic-auth to protect the
/stats
endpoint - Uses middleware to log requests
- Templates to render HTML
That's quite a lot of functionality for a few lines of code! Let's see how Rust stacks up!
A Rust web service
Historically, Rust didn't have a good story for web services. There were a few frameworks, but they were quite low-level. Only with the emergence of async/await, did the Rust web ecosystem really take off. Suddenly, it was possible to write highly performant web services without a garbage collector and with fearless concurrency.
We will see how Rust compares to Go in terms of ergonomics, performance and safety. But first, we need to choose a web framework.
Which web framework?
If you're looking to get a better overview of Rust web frameworks as well as their strengths and weaknesses, we recently did a Rust web framework deep-dive.
For the purpose of this article, we consider two web frameworks: Actix and Axum.
Actix is a very popular web framework in the Rust community. It is based on the actor model and uses async/await under the hood. In benchmark, it regularly shows up as one of the fastest web frameworks in the world.
Axum on the other hand is a new web framework that is based on tower, a library for building async services. It is quickly gaining popularity. It is also based on async/await.
Both frameworks are very similar in terms of ergonomics and performance. They both support middleware and routing. Each of them would be a good choice for our web service, but we will go with Axum, because it ties in nicely with the rest of the ecosystem and has gotten a lot of attention recently.
Routing
Let's start the project with a cargo new forecast
and add the following dependencies to our Cargo.toml
.
(We will need a few more, but we will add them later.)
Let's create a little skeleton for our web service, which doesn't do much.
The main
function is pretty straightforward. We create a router and bind it to a socket address. The index
, weather
and stats
functions are our handlers. They are async functions that return a string. We will replace them with actual logic later.
Let's run the web service with cargo run
and see what happens.
Okay, that works. Let's add some actual logic to our handlers.
Axum macros
Before we move on, I'd like to mention that axum has some rough edges.
E.g. it will yell at you if you forgot to make your handler function async.
So if you run into Handler<_, _> is not implemented
errors, add the axum-macros crate and annotate your handler with #[axum_macros::debug_handler]
. This will give you much better error messages.
Fetching the latitude and longitude
Let's write a function that fetches the latitude and longitude for a given city from an external API.
Here are the structs representing the response from the API:
In comparison to Go, we don't use tags to specify the field names. Instead, we
use the #[derive(Deserialize)]
attribute from serde to automatically derive the
Deserialize
trait for our structs. These derive macros are very powerful and
allow us to do a lot of things with very little code, including handling parsing
errors for our types. It is a very common pattern in Rust.
Let's use the new types to fetch the latitude and longitude for a given city:
The code is a bit less verbose than the Go version. We don't have to
write if err != nil
constructs, because we can use the ?
operator
to propagate errors. This is also mandatory, as each step returns a
Result
type. If we don't handle the
error, we won't get access to the value.
That last part might look a bit unfamiliar:
A few things are happening here:
response.results.get(0)
returns anOption<&LatLong>
. It is anOption
because theget
function might returnNone
if the vector is empty.cloned()
clones the value inside theOption
and converts theOption<&LatLong>
into anOption<LatLong>
. This is necessary, because we want to return aLatLong
and not a reference. Otherwise, we would have to add a lifetime specifier to the function signature and it makes the code less readable.ok_or("No results found".into())
converts theOption<LatLong>
into aResult<LatLong, Box<dyn std::error::Error>>
. If theOption
isNone
, it will return the error message. Theinto()
function converts the string into aBox<dyn std::error::Error>
.
An alternative way to write this would be:
It is a matter of taste which version you prefer.
Rust is an expression-based language, which means that we don't have to
use return
to return a value from a function. Instead, the last value
of a function is returned.
We can now update our weather
function to use fetch_lat_long
.
Our first attempt might look like this:
First we print the city to the console, then we fetch the latitude and longitude and unwrap (i.e. "unpack") the result. If the result is an error, the program will panic. This is not ideal, but we will fix it later.
We then use the latitude and longitude to create a string and return it.
Let's run the program and see what happens:
Furthermore, we get this output:
The city
parameter is empty. What happened?
The problem is that we are using the String
type for the city
parameter. This type is not a valid extractor.
We can use the Query
extractor instead:
This will work, but it is not very idiomatic. We have to unwrap
the Option
to get the city. We also need to pass *city
to the
format!
macro to get the value instead of the reference. (It's
called "dereferencing" in Rust lingo.)
We could create a struct that represents the query parameters:
We can then use this struct as an extractor and avoid the unwrap
:
Cleaner! It's a little more involved than the Go version, but it's also more type-safe. You can imagine that we can add constraints to the struct to add validation. For example, we could require that the city is at least 3 characters long.
Now about the unwrap
in the weather
function.
Ideally, we would return an error if the city is not found. We can do
this by changing our return type.
In axum, anything that implements IntoResponse
can be returned from handlers, however it is advisable to return
a concrete type, as there are
[some caveats with returning impl IntoResponse
] (https://docs.rs/axum/latest/axum/response/index.html)
In our case, we can return a Result
type:
This will return a 404
status code if the city is not found.
We use match
to match on the result of fetch_lat_long
. If it is
Ok
, we return the weather as a String
. If it is Err
, we return
a StatusCode::NOT_FOUND
.
We could also use the map_err
function to convert the error into a
StatusCode
:
This variant has the advantage that we the control flow is more linear: we handle the error right away and can then continue with the happy path. On the other hand, it takes a while to get used to these combinator patterns until they become second nature.
In Rust, there are usually multiple ways to do things. It's a matter of taste which version you prefer. In general, keep it simple and don't overthink it.
In any case, let's test our program:
and
Let's write our second function, which will return the weather for a given latitude and longitude:
Here we make the API request and return the raw response body as a String
.
We can extend our handler to make the two calls in succession:
This would work, but it would return the raw response body from the Open Meteo API. Let's parse the response and return the data similar to the Go version.
As a reminder, here's the Go definition:
And here is the Rust version:
While we're at it, let's also define the other structs we need:
We can now parse the response body into our structs:
Let's adjust the handler. The easiest way to make it compile is to
return a String
:
Note how we mix the parsing logic with the handler logic. Let's clean this up a bit by moving the parsing logic into a constructor function:
That's already a little bit better.
What's distracting is the map_err
boilerplate.
We can remove that by introducing a custom error type.
For instance, we can follow the example in the axum
repository and use anyhow,
a popular crate for error handling:
Let's copy the code from the example into our project:
You don't have to fully understand this code. Suffice to say that will set up the error handling for the application so that we don't have to deal with it in the handler.
We have to adjust the fetch_lang_long
and fetch_weather
functions
to return a Result
with an anyhow::Error
:
and
At the price of adding a dependency and adding the additional boilerplate for error handling, we managed to simplify our handler quite a bit:
Templates
axum
doesn't come with a templating engine.
We have to pick one ourselves.
I usually use either tera or askama with a slight preference for askama
because it supports compile-time syntax checks. With that, you cannot accidentally introduce typos in a template. Every variable you use in a template has to be defined in the code.
Let's create a templates
directory and add a weather.html
template,
similar to the Go table template we created earlier:
Let's convert our WeatherDisplay
struct into a Template
:
and our handler becomes:
It was a bit of work to get here, but we now have a nice separation of concerns without too much boilerplate.
If you open the browser at http://localhost:3000/weather?city=Berlin
, you should see the weather table.
Adding our input mask is easy. We can use the exact same HTML we used for the Go version:
and here is the handler:
Let's move on to storing the latitudes and longitudes in a database.
Database access
We will use sqlx for database access. It's a very popular crate that supports multiple databases. In our case, we will use Postgres, just like in the Go version.
Add this to your Cargo.toml
:
We need to add a DATABASE_URL
environment variable to our .env
file:
If you don't have Postgres running still, you can start it with the same Docker snippet from our Go section.
With that, let's adjust our code to use the database.
First, the main
function:
Here's what changed:
- We added a
DATABASE_URL
environment variable and read it inmain
. - We create a database connection pool with
sqlx::PgPool::connect
. - Then we pass the pool to
with_state
to make it available to all handlers.
In each route, we can (but don't have to) access the database pool like this:
To learn more about State
, check out the documentation.
To make our data fetchable from the database, we need to add a FromRow
trait to our structs:
Let's add a function to fetch the latitudes and longitudes from the database:
and finally, let's update our weather
route to use the new function:
And that's it! We now have a working web app with a database backend. The behavior is identical to before, but now we cache the latitudes and longitudes.
Middleware
The last feature that we're missing from our Go version is the /stats
endpoint. Remember that it shows the recent queries and is behind basic auth.
Let's start with basic auth.
It took me a while to figure out how to do this. There are numerous authentication libraries for axum, but very little information on how to do basic auth.
I ended up writing a custom middleware, that would
- check if the request has an
Authorization
header - if it does, check if the header contains a valid username and password
- if it does, return an "unauthorized" response and a
WWW-Authenticate
header, which instructs the browser to show a login dialog.
Here's the code:
FromRequestParts is a trait that allows us to extract data from the request.
There's also FromRequest, which consumes the entire request body and can thus only be run once for handlers. In our case, we just need to read the Authorization
header, so FromRequestParts
is enough.
The beauty is, that we can simple add the User
type to any handler and it will extract the user from the request:
Now about the actual logic for the /stats
endpoint.
Deployment
Lastly, let's talk about deployment.
Since both languages compile to a statically linked binary, they can be hosted on any Virtual Machine (VM) or Virtual Private Server (VPS). That is amazing because it means that you can run your application natively on bare metal if you like.
Another option is to use containers, which run your application in an isolated environment. They are very popular because they are easy to use and can be deployed virtually anywhere.
For Golang, you can use any cloud provider that supports running static binaries or containers. One of the more popular options is Google Cloud Run.
You can of course also use containers to ship Rust, but there are other options, too. One of them is Shuttle, of course, and the way it works is different to other services: You don't need to build a Docker image and push it to a registry. Instead, you just push your code to a Git repository and Shuttle will build and run the binary for you.
Thanks to Rust's procedural macros, you can enhance your code with additional functionality quickly.
All it takes to get started is #[shuttle_runtime::main]
on your main function:
To get started, install the Shuttle CLI and dependencies.
You can utilize cargo binstall, a Cargo plugin designed to install binaries from crates.io. First, ensure you have the plugin installed. After that, you'll be able to install the Shuttle CLI:
Let's modify our main
function to use Shuttle.
Note how we no longer need the port binding, as Shuttle will take care of that
for us! We just hand it the router and it will take care of the rest.
Next, let's set up our production postgres database. There's a macro for that, too.
and
See that part about the schema? That's how we initialize our database with our existing table definitions. Migrations are also supported through sqlx and sqlx-cli.
We got rid of a lot of boilerplate code and can now deploy our app with ease.
When it's done, it will print the URL to the service. It should work just like before, but now it's running on a server in the cloud. 🚀
A Comparison Between Go And Rust
Let's see how the two versions stacked up against each other.
The Go version
The Go version is very simple and straightforward. We only needed to add two
dependencies: Gin
(the web framework) and sqlx
(the database driver). Apart
from that, everything was provided by the standard library: the templating engine, the
JSON parser, the datetime handling, etc.
Even though I'm personally not a big fan of Go's templating engine and error handling mechanisms, I felt productive throughout the entire development process. We could have used an external templating library, but we didn't need to as the built-in one was just fine for our use case. If you're looking to leverage the power of Go for your projects, you might want to hire a Golang developer.
The Rust version
The Rust code is a little more involved. We needed to add a lot of dependencies to get the same functionality as in Go. For example, we needed to add a templating engine, a JSON parser, a datetime library, a database driver, and a web framework.
This is by design. Rust's standard library is very minimal and only provides the most basic building blocks. The idea is that you can pick and choose the dependencies that you need for your project. It helps the ecosystem to evolve faster and allows for more experimentation while the core of the language stays stable.
Even though it took longer to get started, I enjoyed the process of working
my way up to higher levels of abstraction. At no point did I feel like I was
stuck with a suboptimal solution. With the proper abstractions in place,
such as the ?
operator and the FromRequest
trait, the code felt easy to read
without any boilerplate or unnecessarily verbose error handling.
Summary
-
Go:
- Easy to learn, fast, good for web services
- Batteries included. We did a lot with just the standard library. For example, we didn't need to add a templating engine or a separate auth library.
- Our only external dependencies were
Gin
andsqlx
-
Rust:
- Fast, safe, evolving ecosystem for web services
- No batteries included. We had to add a lot of dependencies to get the same functionality as in Go and write our own small middleware.
- The final handler code was free from distracting error handling, because
we used our own error type and the
?
operator. This makes for very readable code, at the cost of having to write additional adapter logic. The handlers are succinct, and there's a natural separation of concerns.
That begs the question...
Is Rust better than Go, or will Rust replace Go?
Personally, I'm a big fan of Rust and I think it's a great language for web services. But there are still a few rough edges and missing pieces in the ecosystem.
Especially for newcomers, the error messages when using axum can at times be quite cryptic. For example, a common one is this error message, which occurs on routes that do not implement the handler trait because of type mismatches:
For this case, I recommend the axum debug_handler
,
which simplifies the error messages quite a bit. Read more about it in their
documentation.
In comparison to Go, the authorization part was also more involved. In Go, we could just use a middleware and be done with it. In Rust, we had to write our own middleware and error type. This is not necessarily a bad thing, but it requires some research in the axum docs to find the right solution. Granted, basic auth is not a common use case for real-world applications, and there are plenty of advanced auth libraries to choose from.
The mentioned issues are not deal breakers and mostly papercuts related to specific crates. Core Rust has reached a point of stability and maturity that makes it suitable for production use. The ecosystem is still evolving, but it's already in a good place.
On the other hand, I personally find the final Go code a little bit too verbose.
The error handling is very explicit, but it also distracts from the actual
business logic.
In general, I found myself reaching for higher-level abstractions in Go (like the
aforementioned FromRequest
trait in the Rust version). The final Rust
code feels more succinct. It felt like the Rust compiler was quietly guiding me
towards a better design throughout the entire process.
There's certainly a higher upfront cost to using Rust, but the ergonomics are
great once you get over the initial scaffolding phase.
I don't think one language is better than the other. It's a matter of taste and personal preference. The philosophies of the two languages are quite different, but they both allow you to build fast and reliable web services.
Should I use Rust or Go in 2023?
If you're just starting out with a new project, and you and your team could freely pick a language to use, you might be wondering which one to choose.
It depends on the timeframe for the project and your team's experience. If you're looking to get started quickly, Go might be the better choice. It offers a batteries-included development environment and is great for web apps.
However, don't underestimate the long-term benefits of Rust. Its rich type system paired with its awesome error handling mechanisms and compile-time checks can help you build apps which are not only fast but also robust and extensible.
With regards to developer velocity, Shuttle can substantially lower the operational burden from running Rust code in production. As we've seen, you don't need to write a Dockerfile to get started and your code builds natively in the cloud, which allows for very fast deployment- and iteration cycles.
So if you're looking for a long-term solution, and you're willing to invest in learning Rust, I'd say it's a great choice.
I invite you to compare both solutions and decide for yourself which one you like better.
In any case, it was fun to build the same project in two different languages and look at the differences in idioms and ecosystem. Even though the end result is the same, the way we got there was quite different.