In this post, I want to go from step by step what it would look like building a taskr binary in both Go and Rust side by side so you can see conversion conventions and why Rust wins in the battle between the gopher and the crab.
Starting Point
package main
import "fmt"
func main() {
fmt.Println("Hello World")
}
fn main() {
println!("Hello World")
}
From both of these applications, you should be working in a directory structure that looks like:
~/work/personal/learning-go/taskr/main.go
~/work/personal/learning-rust/taskr/main.rs
To get the ~/work/personal/learning-go directory, create it and then create a new Go file using:
mkdir -p ~/work/personal/learning-go/taskr
cd ~/work/personal/learning-go/taskr
go mod init taskr
To get the ~/work/personal/learning-rust/taskr directory, run:
mkdir -p ~/work/personal/learning-rust
cargo new taskr
From that command you'll get a Cargo.toml file which will be versioned to 0.1.0.
The program's data structure
We're building out a task, so lets define what that looks like in Go and in Rust.
type Status string
const (
StatusTodo Status = "Todo"
StatusDone Status = "Done"
)
type Task struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Status Status `json:"status"`
}
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
enum Status {
Todo,
Done,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct Task {
id: u32,
title: String,
status: Status,
}
| Decorator | Usage |
|---|---|
Debug |
lets me print the value using {:?} format |
Serialize |
generate JSON code at compile time |
Deserialize |
read JSON code at compile time |
Clone |
allows you to duplicate a value |
PartialEq |
allows equality comparisons between Status values |
These decorators will be used throughout the program later. Specifically, the Debug will be used in println!() statements. serde import provides Serialize and Deserialize in order to engage with JSON files at compile time. Without PartialEq, the status would not be comparable. It's required.
Let's define what the print_usage() function is going to look like in Go and in Rust.
func printUsage() {
fmt.Println("taskr - A simple task manager")
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" taskr add <title> Add a new task")
fmt.Println(" taskr list List all tasks")
fmt.Println(" taskr done <id> Mark a task as done")
fmt.Println(" taskr delete <id> Delete a task")
fmt.Println(" taskr search <query> Search for a task")
}
fn print_usage() {
println!("taskr - A simple task manager");
println!();
println!("Usage:");
println!(" taskr add <title> Add a new task");
println!(" taskr list List all tasks");
println!(" taskr done <id> Mark a task as done");
println!(" taskr delete <id> Delete a task");
println!(" taskr search <query> Search for a task");
}
This should go above the main() func in both main.go and main.rs respectively.
| Command | About |
|---|---|
taskr add <title> |
Add a new task |
taskr list |
List all tasks |
taskr done <id> |
Mark a task as done |
taskr delete <id> |
Delete a task |
taskr search <id> |
Search for a task |
Implementing the print_usage() func in both Go and Rust is very simple and is done in the main() func respectively:
func main() {
if len(os.Args) < 2 {
printUsage()
return
}
}
fn main() {
// .collect() returns the .args() into a Vec<String> format
let args: Vec<String> = std::env::args().collect();
// ensure that there are enough arguments to run the program
if args.len() < 2 {
print_usage();
}
}
The big difference here is the Go os.Args and the Rust std::env::args().collect(). Clearly, Rust is more verbose, and without .collect() the value of the .args() could be a tuple of <String, Err>, but .collect() returns a Vec<String> if there are no Err thrown.
With this basic program functionality, we can implement in the main() func in both languages what that switching is going to look like parsing the 2nd and 3rd arguments respectively.
func main(){
// ... existing
switch args[1] {
// 1. taskr search <query>
case "search":
// 2. taskr add <title>
case "add":
// 3. taskr list
case "list":
// 4. taskr done <id>
case "done":
// 5. taskr delete <id>
case "delete":
// 6. taskr <else> catch-all
default:
}
//- end of main
}
fn main() {
// ... existing
match args[1].as_str() {
// 1. taskr search <query>
"search" => {}
// 2. taskr add <title>
"add" => {}
// 3. taskr list
"list" => {}
// 4. taskr done <id>
"done" => {}
// 5. taskr delete <id>
"delete" => {}
// 6. taskr <else> catch-all
_ => {
eprintln!("Invalid command");
print_usage();
std::process::exit(1); // SIGTERM 1
}
} //- match arg[1]
//- end of main
}
The match (rust) and switch (go) statements are both reading from args[1]. The 0 in args[0] would be the filename of the binary running. In the example of taskr list, the args[0] is taskr and args[1] is list. In rust, the .as_str() is used to ensure that the underlying type is a string, since the statement is a String in "statement" => { } (rust) and case "statement": (go).
Next, we are going to be implementing the TaskStore, but before we do that, we need to ensure that our programs respectively have the correct imports.
import (
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
)
// serde is used for JSON structure support at compile time
use serde::{Deserialize, Serialize};
// read and write on file functions
use std::fs;
// access the io::Result type for propagating I/O errors
use std::io;
// owned mutable file path
use std::path::PathBuf;
These imports allow each of the programs to respectively handle the functionality needed. Within Go, you'll interact with the os package for I/O related work and in Rust, you interact with std::io instead. Files are handled in Go using the os package, as well as the fs and ioutil packages. Rust consolidates it into a two packages called std::io and std::fs. Both languages are designed to be cross platform natively. One last thing to point out about this is the std::path::PathBuf and how its very similar to Go's filepath package offering. In Rust, the PathBuf type is specifically for filesystem paths. On Windows, you split your directory tree using \ and on Linux, you split your directory tree using /. Those differences are handled under the hood by the filepath (Go) and std::path::PathBuf (Rust) automatically.
The Task Store
The bulk of the logic of the application is going to expose TaskStore in both Go and Rust. How these are implemented matter and you'll see why that is.
type TaskStore struct {
tasks []Task
filePath string
}
struct TaskStore{
tasks: Vec<Task>,
file_path: PathBuf,
}
Both of these snippets show you how similar Go and Rust can appear. The []Task in Go becomes a Vec<Task> in Rust and the filePath uses Rust's PathBuf. In Go, the struct can use and store the filePath as a string since filepath package returns only String types back - Rust gives you an entirely new type called PathBuf whereas Go gives you a wrapper for it.
When implementing the methods on the TaskStore, this is where Go and Rust diverge quite radically.
func NewTaskStore(filePath string) (*TaskStore, error) {}
func (s *TaskStore) Save() error {}
func (s *TaskStore) Add(title string) {}
func (s *TaskStore) Complete(id uint32) error {}
func (s *TaskStore) Delete(id uint32) error {}
func (s *TaskStore) List() {}
func (s *TaskStore) Search(query string) []*Task {}
impl TaskStore {
fn new(file_path: PathBuf) -> io::Result<Self> {}
fn save(&self) -> io::Result<()> {}
fn add(&mut self, title: String) {}
fn complete(&mut self, id: u32) -> Result<(), String> {}
fn delete(&mut self, id: u32) -> Result<(), String> {}
fn list(&self) {}
fn search(&self, query: &str) -> Vec<&Task> {}
}
There is a pretty clean 1:1 relationship between the implemented methods of the TaskStore between Go and Rust and specifically what I want to note here for you is how in Rust everything is wrapped inside of the impl TaskStore {} block itself, whereas in Go, this can be literally anywhere at the root level of the file inside the package main project. The order of the routes does not matter in either language.
Rust has a convention called io::Result<Self> which in Go simply looks like (<Type>, error). The NewTaskStore method in Go is unattached per-se to the TaskStore struct itself, but it does return the Go equivalent of (*TaskStore, error) as io::Result<Self>. When io::Result<Self> is used, the method must also include Ok(()) in order to satisfy the contract.
At this point, let's ensure that our Go program has two little helper functions in it in order to improve runtime readability - this is common in Go.
func parseID(arg string) uint32 {
id, err := strconv.ParseUint(arg, 10, 32)
if err != nil {
fmt.Fprintf(os.Stderr, "Invalid task ID: %s\n", arg)
os.Exit(1)
}
return uint32(id)
}
func mustSave(s *TaskStore) {
if err := s.Save(); err != nil {
fmt.Fprintf(os.Stderr, "Error saving tasks: %v\n", err)
os.Exit(1)
}
}
This application does not need or use helper functions in the Rust implementation. In Rust we're using u32 for the id and in Go that is a uint32 which imports the strconv package. Rust does not need to import any packages.
Before implementing the specifics in the TaskStore, lets consume it in the main() func.
func main() {
// ... after checking the length of args
store, err := NewTaskStore("tasks.json")
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading tasks: %v\n", err)
os.Exit(1)
}
// ... before switching args[1]
}
fn main() -> io::Result<()> {
// ... after checking the length of args
let database = PathBuf::from("tasks.json");
let mut store = match TaskStore::new(database){
Ok(store) => store,
Err(e) => {
eprintln!("Error loading tasks: {}", e);
std::process::exit(1); // SIGTERM 1
}
};
// ... before matching args[1]
}
Both of these implementations will leverage the current working directory and consume the resource tasks.json if it exists. If it does not exist, the program will create it, if it exists, the program will overwrite it.
In Rust, the TaskStore is consumed slightly differently than in Go because of the impl TaskStore {} differences. In Rust the TaskStore must have mut after let and before the name of the variable in order to indicate to the compiler that the value of store is eligible for changing in the future. Since the TaskStore::new(database) returns with an io::Result<Self>, we can match against it when assigning it to the store variable.
The Go tuple that io::Result<Self> is satisfying is (*TaskStore, error) and in Rust, the first component *TaskStore is represented in by the Ok() invocation. At the end of the various impl TaskStore {} methods, you'll see an Ok(()) at the end of each method ; this invokes the contract and exits the method since we're good. Otherwise, your program will not compile. When assigning a value to store and executing TaskStore::new(database), each component of the output gets matched to the Ok(store) => or Err(e) => {} blocks respectively. When we receive an Ok(()) then that returned store from Ok() is then assigned to the let mut store variable.
Otherwise, if Err(e) satisfies, it means that something went wrong and we'll print the error and exit with status code 1. In Go, we use os.Exit(1) and in Rust we use std::process::exit(1);. In Go, we can use the fmt or slog or verbose packages to write to STDOUT and STDERR, and in Rust we use eprintln!() for STDERR and println!() for STDOUT respectively.
Implementing new TaskStore
We have stubbed out each of our methods, and at this point we need to implement the body of these methods in two locations. We're going to implement the TaskStore now, and that means populating each of the methods in TWO locations. First in the impl TaskStore {} block for Rust and inside of the func (s *TaskStore) ... methods for Go.
| Method | Go Signature | Rust Signature |
|---|---|---|
new |
func NewTaskStore(filePath string) (*TaskStore, error) |
fn new(file_path: PathBuf) -> io::Result<Self> |
save |
func (s *TaskStore) Save() error |
fn save(&self) -> io::Result<()> |
list |
func (s *TaskStore) List() |
fn list(&self) |
complete |
func (s *TaskStore) Complete(id uint32) error |
fn complete(&mut self, id: u32) -> Result<(), String> |
delete |
func (s *TaskStore) Delete(id uint32) error |
fn delete(&mut self, id: u32) -> Result<(), String> |
add |
func (s *TaskStore) Add(title string) |
fn add(&mut self, title: String) |
search |
func (s *TaskStore) Search(query string) []*Task |
fn search(&self, query: &str) -> Vec<&Task> |
TaskStore's new Implemented
func NewTaskStore(filePath string) (*TaskStore, error) {
tasks := []Task{}
data, err := os.ReadFile(filePath)
if err == nil {
if jsonErr := json.Unmarshal(data, &tasks); jsonErr != nil {
tasks = []Task{}
}
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
return &TaskStore{tasks: tasks, filePath: filePath}, nil
}
fn new(file_path: PathBuf) -> io::Result<Self> {
let tasks = if file_path.exists() {
let data = fs::read_to_string(&file_path)?;
serde_json::from_str(&data).unwrap_or_else(|_| Vec::new() )
} else {
Vec::new()
};
Ok(Self { tasks, file_path })
}
The most notable thing to understand in this code block is the difference between how Go and Rust respectively handle JSON. To be clear up front: both languages parse the actual data at runtime โ no compiler can validate a file that doesn't exist until the program runs. The real difference is where the mapping code comes from and how failure is surfaced. In Go, json.Unmarshal uses reflection at runtime to inspect your struct's fields and figure out how to map the data onto them, and it reports problems as an error value you check with jsonErr != nil โ a check the compiler will let you skip entirely. It's also forgiving by default: unknown fields are ignored and missing fields silently become zero values, so irregular JSON can flow through your program without any error at all. In Rust, the #[derive(Deserialize)] macro generates the mapping code at compile time โ no reflection โ and the build fails outright if any field's type can't be deserialized. At runtime, serde_json::from_str returns a Result, and the type system won't let you touch the parsed value until you've decided what happens on failure โ with ?, a match, or a fallback like .unwrap_or_else(|_| Vec::new()). Mistyped or missing fields are loud errors by default instead of silent zero values, and the generated code is typically faster than reflection-based decoding as a bonus. Ultimately, Rust doesn't validate your JSON before compilation โ it forces you to handle irregular JSON before your code will compile, which is what actually takes the sting out of the most painful part of Go development: interacting with irregular JSON files.
Literally, modeling an irregular JSON file in Rust can take you noticeably longer to get right than in Go โ every optional field, inconsistent type, and quirk has to be spelled out with Option<T>, #[serde(default)], or a custom deserializer before the compiler lets you move on. The investment of time in this, on the surface, feels like it's a waste and unnecessary โ but years down the road, the payoff arrives at runtime, not at recompile time: when that feed inevitably changes shape, the Rust program fails loudly at the parse boundary, while the Go program may keep running on silent zero values. What neither language will do is pull the rug out from under old code. Go's compatibility promise explicitly guarantees that programs written for Go 1 keep compiling and behaving the same โ encoding/json included โ and even the new encoding/json/v2 ships as a separate, opt-in package rather than a change to the old one. Rust makes the same commitment through its edition system: new editions are opt-in per crate, and old ones keep compiling on new compilers (rare soundness fixes aside). So who actually gives you write once, compile 25 years later with zero effort? C mostly does, though modern compilers now reject old habits like implicit int. C++ mostly does, though the standard really does remove things โ auto_ptr, register, pre-standard headers โ so "zero effort" is optimistic. Rust hasn't existed long enough to prove it (1.0 shipped in 2015), but its stability policy is built for exactly that. And Go, far from merely trying, has one of the strictest backward-compatibility records in the industry. As for Python, PHP and Ruby โ their major-version breaks (Python 2โ3 most famously) are stewardship choices, not something true by definition and design of high-level languages; and "compile 25 years later" isn't quite the right test for interpreted languages anyway.
We consumed the new method in the main() func when we established our let mut store (rust) and store, err := NewTaskStore("tasks.json") (go).
TaskStore's list Implemented
func (s *TaskStore) List() {
if len(s.tasks) == 0 {
fmt.Println("No tasks yet. Add one with: taskr add \"your task\"")
return
}
for _, task := range s.tasks {
marker := "[ ]"
if task.Status == StatusDone {
marker = "[X]"
}
fmt.Printf("%s %d - %s\n", marker, task.ID, task.Title)
}
}
fn list(&self) {
if self.tasks.is_empty() {
println!("No tasks yet. Add one with: taskr add \"your task\"");
return;
}
for task in &self.tasks {
let market = match task.status {
Status::Todo => "[ ]",
Status::Done => "[X]",
};
println!("{} {} - {}", market, task.id, task.title);
}
}
Respectively, these two methods look pretty much the same. Specifically in Rust we're leveraging the borrower for &self.tasks and in Go we just range over them without needing to indicate memory usage. Without the Debug derived decorator attacked to the Task struct, the println!() methods that use {} wouldn't compile.
TaskStore's add Implemented
func (s *TaskStore) Add(title string) {
var maxID uint32
for _, t := range s.tasks {
if t.ID > maxID {
maxID = t.ID
}
}
task := Task{
ID: maxID + 1,
Title: title,
Status: StatusTodo,
}
fmt.Printf("Added task %d: %s\n", task.ID, task.Title)
s.tasks = append(s.tasks, task)
}
fn add(&mut self, title: String) {
let id = self.tasks.iter() // use the iterator
.map(|t| t.id) // use only the id field
.max() // select the maximum value in the set
.unwrap_or(0) + 1; // if no err, use # else use 0 + 1
let task = Task {
id, // short for `id: id,`
title, // short for `title: title,`
status: Status::Todo,
};
println!("Added task {}: {}", task.id, task.title);
self.tasks.push(task);
}
Most notably between this Go and Rust code you can see the manner for acquiring the next ID in the set is radically different. In Rust we are using the .iter() in order to .map() then .max() with a final check of .unwrap_or(0) in order to get an iterable list of IDs via the .map() extraction of just the id field, followed by the .max() choice. This quickly returns the largest integer of the result and then attempts to + 1 at the end of it. In Rust this expression is a simple statement and in Go its multiple lines using maxID inside the method.
In Rust without the &mut on the self argument of the method, the self.tasks.push(task); would fail because the compiler is missing the &mut on self in order to actually write via .push(). In Go, we simply use append(...).
Both languages now need to take this impl TaskStore { "add" => {} } and func (s *TaskStore) Add(title string) and consume them in the main() func.
func main() {
// ... existing after \`switch args[1]\`
case "add":
if len(args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: taskr add <title>")
os.Exit(1)
}
store.Add(strings.Join(args[2:], " "))
mustSave(store)
}
// ... existing before \`default:\` case
}
fn main() {
// ... existing after `match args[1].to_str() {`
// 2. taskr add <title>
"add" => {
if args.len() < 3 {
eprintln!("Usage: taskr add <title>");
std::process::exit(1); // SIGTERM 1
}
let title = args[2..].join(" ");
store.add(title);
store.save()?;
Ok(())
}
// ... existing before `_ => {}` match
}
Our Go code is consuming the mustSave helper method and our Rust code is calling .add() and .save() endpoints on the TaskStore impl.
TaskStore's delete Implemented
func (s *TaskStore) Delete(id uint32) error {
for i, t := range s.tasks {
if t.ID == id {
fmt.Printf("Removed task %d: %s\n", t.ID, t.Title)
s.tasks = append(s.tasks[:i], s.tasks[i+1:]...)
return nil
}
}
return fmt.Errorf("task %d not found", id)
}
fn delete(&mut self, id: u32) -> Result<(), String> {
let pos = self.tasks.iter() // get an iterator
.position(|t| t.id == id) // find the index of the id
.ok_or_else(||
format!("Task {} not found", id)
)?;
let removed = self.tasks.remove(pos);
println!("Removed task {}: {}", removed.id, removed.title);
Ok(())
}
As you can see - these methods look so similar and that's because Go and Rust compliment each other, like C and C++ complimented each other. They are designed to be similar, but one for more lower level work and the other for more higher level data driven work. Either way, when you implement these methods, you see their implementations even look similar.
case "delete":
if len(args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: taskr delete <id>")
os.Exit(1)
}
if err := store.Delete(parseID(args[2])); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
mustSave(store)
"delete" => {
if args.len() < 3 {
eprintln!("Usage: taskr delete");
std::process::exit(1);
}
let id: u32 = args[2].parse().unwrap_or_else(|_| {
eprintln!("Invalid task ID: {}", args[2]);
std::process::exit(1);
});
if let Err(e) = store.delete(id) {
eprintln!("{}", e);
std::process::exit(1); // SIGTERM 1
}
store.save()?;
Ok(())
}
Most notably in the differences between these two implementations, you can see the Go helper method parseID being used here where Rust just leverages .parse().unwrap_or_else(|_| {} ) instead.
TaskStore's complete | done Implemented
func (s *TaskStore) Complete(id uint32) error {
for i := range s.tasks {
if s.tasks[i].ID == id {
s.tasks[i].Status = StatusDone
fmt.Printf("Completed task %d: %s\n", s.tasks[i].ID, s.tasks[i].Title)
return nil
}
}
return fmt.Errorf("task %d not found", id)
}
fn delete(&mut self, id: u32) -> Result<(), String> {
let pos = self.tasks.iter() // get an iterator
.position(|t| t.id == id) // find the index of the id
.ok_or_else(||
format!("Task {} not found", id)
)?;
let removed = self.tasks.remove(pos);
println!("Removed task {}: {}", removed.id, removed.title);
Ok(())
}
The conventions between Rust and Go become very visibly noticeable in this manner of presentation.
case "done":
if len(args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: taskr done <id>")
os.Exit(1)
}
if err := store.Complete(parseID(args[2])); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
mustSave(store)
"done" => {
if args.len() < 3 {
eprintln!("Usage: taskr done");
std::process::exit(1); // SIGTERM 1
}
let id: u32 = args[2].parse().unwrap_or_else(|_| {
eprintln!("Invalid task ID: {}", args[2]);
std::process::exit(1); // SIGTERM 1
});
if let Err(e) = store.complete(id) {
eprintln!("{}", e);
std::process::exit(1); // SIGTERM 1
}
store.save()?;
Ok(())
}
TaskStore's search Implemented
func (s *TaskStore) Search(query string) []*Task {
queryLower := strings.ToLower(query)
var results []*Task
for i := range s.tasks {
if strings.Contains(strings.ToLower(s.tasks[i].Title), queryLower) {
results = append(results, &s.tasks[i])
}
}
return results
}
fn search(&self, query: &str) -> Vec<&Task> {
let query_lower = query.to_lowercase();
self.tasks.iter() // get an iterator
.filter(|task| // each task loop
task.title.to_lowercase() // get lowercase title
.contains(&query_lower) // match with query_lower
)
.collect() // gather into vector
}
Specifically, the Rust is returning Vec<&Task> which is a referenced object explicitly from the .collect() output.
case "search":
if len(args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: taskr search <query>")
os.Exit(1)
}
query := strings.Join(args[2:], " ")
results := store.Search(query)
if len(results) == 0 {
fmt.Printf("No tasks found matching %q\n", query)
} else {
fmt.Printf("Found %d tasks matching: %s\n", len(results), query)
for _, task := range results {
marker := "[ ]"
if task.Status == StatusDone {
marker = "[X]"
}
fmt.Printf("%s %d: %s\n", marker, task.ID, task.Title)
}
}
"search" => {
if args.len() < 3 {
eprintln!("Usage: taskr search <query>");
std::process::exit(1); // SIGTERM 1
}
let query = args[2..].join(" ");
let results = store.search(&query);
if results.is_empty() {
println!("No tasks found matching \"{}\"", query);
} else {
println!("Found {} tasks matching: {}", results.len(), query);
for task in &results {
let marker = match task.status {
Status::Todo => "[ ]", // incomplete items
Status::Done => "[X]", // completed items
};
println!("{} {}: {}", marker, task.id, task.title);
}
}
Ok(())
}
Conclusion
You can view the Go and Rust full source code respectively at either link provided.
This tutorial was designed to help bring Go engineers over to Rust by completing a command line task manager application in both languages to see how they are both the similar and different.
Are you coming into Rust from Go like I am? If so, what are some of your favorite resources to use and learn from?
Top comments (0)