Giving Go a Try

  • Updated on 4th Sep 2025

In this post, I'll document my experience building an app in Go, which is a new-to-me language. Keep reading if you want to know more about how I built this, and my first impressions of Go.

If you just want to see the code, hop on over to the repository.

Background

If you know me, or you've read my writing before, you'll know that I'm a Java developer by day, and a Rustacean by night. I've also done some Python here and there as the situation called for, but that's been about all I've worked with in the past 5 years or so. While I don't think Java will ever go away, and I still love Rust, I was itching to try something new.

For a moment, let's rewind back to late 2019, I was working with a small group on a project that I was pretty excited about. It was a Python app that, at the time, we felt had a lot of potential. Unfortunately, however, that project ended up fizzling out, but I still remember one thing from back then. The software we had been building processed near real-time data from multiple feeds, and we were quickly outgrowing Python. We'd been talking for some time about a Go port, because one of our contributors was familiar with Go.

I never got a chance to explore Go back then, and I don't really like leaving things unfinished.

Now that I was looking for something new, I decided to just Go for it.

Deciding What to Build

I remember I had a professor back in undergrad that loved to use the "Lazy developers are good developers" line. That professor would be proud because I got a bit lazy when picking a project.

I fired up a chat session with an LLM and asked for a project plan to learn about Go. Specifically, I requested approximately 8 hours of work, basic but flexible requirements, and some focus areas for my learnings. My plan was to treat this as I would any LLM output: a starting point that I would then workshop with my own human intuition.

The plan that I received was remarkably mundane: a simple TODO app with a CLI. I could have come up with this on my own, sure, but I was still thankful to have some notes to go off of. So there I had my plan, I knew what I was going to build.

I was going to make a simple TODO CLI that stored tasks as a JSON document and had basic CRUD operations - not bad.

The rest of this post details my experience building the application.

Getting Started

I began my Go journey at the docs. There happens to be a conveniently-placed "Get Started" button which links to learning resources.

Two that I found particularly helpful were:

I've always been an experiential learner, so I was thrilled to see that there's two example-based resources to learn from. After a brisk pass through the tour, and a couple of cherry-picked examples, I decided it was time to write some code.

Implementation Experience

NOTE: I've kept the plan docuement that I AI-generated in the repository as a reference. I've deviated from it in my actual implementation, but it still served as a decent scaffold.

Creating the Task struct

The first step of the plan is to define the Task struct for storing our TODOs. This sounds perfectly reasonable because this is going to be the primary data type we pass around. I settled on the following fields:

  • Id: a unique integer ID for tasks
  • Title: the name of the task
  • Priority: an enum denoting the low/medium/high priority
  • DueDate: when this task is due
  • Category: an optional user-provided category
  • Status: an enum denoting pending/complete state

This was my first encounter with Go-specific behaviors.

Enums

Go has no built-in "enum" type. Instead, enums are just constants with an opaque type alias. They also typically use a built-in called iota which is a language feature more-or-less purpose-built for enums. Go by Example does a good job of explaining the enum pattern.

Iota

If you click through links from the "Go by Example" link above, you'll undoubtedly end up at the Go blog post on iota.

The iota build-in is essentially an incrementing counter that resets inside a new const declaration block. The idea is that it's easy to assign incrementing values to enums:

type Priority int

const (
  Low     Priority = iota // iota -> 0
  Medium                  // iota -> 1
  High                    // iota -> 2
)

It works well for this purpose, but it also has tons of other uses, and my first impression is that it may be too many.

Iota Iteratively Evaluates Expressions

The initial value can be an expression using iota. This expression gets re-evaluated on each line, with the incrmented iota value:

type DoubleX int

const (
	Zero DoubleX = 2 * iota  // iota -> 2 * 0 = 0
	One                      // iota -> 2 * 1 = 2
	Two                      // iota -> 2 * 2 = 4
)

This is convenient, but it's also a lot of functionality packed into one feature.

Iota - Conclusion

I think that iota is definitely an interesting convenience feature, but I'm not sure yet how I feel about how clever it allows developers to get.

Field Naming

My first inclination with the Task struct was to use lower-case names for the fields. I understood that Go treats these as package-private and though it would be fine. I was operating under the impression that I'd start with private fields and make them public (i.e. use a TitleCase name) as needed. One thing I didn't immediately realize, however, is that Go's built-in json module would ignore them.

In retrospect, this is a reasonable design decision. You probably want your data objects to make all their meaningful fields public anyway; it also makes sense with how the Go implementation works (more on that later).

JSON Field Names

One potential issue with this approach is that it couples the JSON field name with Go's convention-based access modifier system. Luckily, there's a way to decouple these. The json module also uses something called "struct tags". These are string literals that can appear next to struct fields. Other modules can access these values via reflection. The json module has a DSL for controlling the conversion to JSON.

Speaking of JSON marshalling, I really like how the Go standard library makes it easy to define custom output formats for types. Since enums are actually just integers, by default they get written as integer literals in the JSON output. Two easy function implementations can change that:

var priorityToString = map[Priority]string{
	Low:    "LOW",
	Medium: "MEDIUM",
	High:   "HIGH",
}

var stringToPriority = map[string]Priority{
	"LOW":    Low,
	"MEDIUM": Medium,
	"HIGH":   High,
}

func (p Priority) MarshalJSON() ([]byte, error) {
	if str, ok := priorityToString[p]; ok {
		return json.Marshal(str)  // the JSON marshaller writes a string instead of an integer
	}
	return nil, fmt.Errorf("unknown priority: %d", p)
}

func (p *Priority) UnmarshalJSON(b []byte) error {
	var k string  // we parse the string
	// fail if the string isn't in the stringToPriority mapping
	if err := json.Unmarshal(b, &k); err != nil {
		return fmt.Errorf("priority should be a string, got %s", string(b))
	}
	if val, ok := stringToPriority[k]; ok {
		*p = val   // otherwise, set the Priority pointer to that value
		return nil
	}
	return fmt.Errorf("unknown priority: %s", k)
}

The Final Task Struct

After all was said and done, I had a Task struct that:

  • has all fields listed above
  • uses camelCase naming for JSON marshalling
  • uses custom marshal/unmarshal string representation for all enums

It didn't take me very long, and the code was pretty straightforward and fun to write. I also went ahead and added some tests, because I had been abusing my poor main() function to manually test.

Creating the Task Manager

The plan also mentioned creating a task manager class for providing the CRUD operations. Based on the generated examples, I came up with a design for the API.

I originally started off with a Manager struct, but I switched to an interface for a couple of reasons. My primary reason is that I like test driven development (TDD). Interfaces also make it quick and easy to make mock dependencies for testing very specfic scenarios. Here's what I ultimately ended up with:

type Manager interface {
	AddTask(title string, priority Priority, getDueDate func(time.Time) DueDate, category string) (*Task, error)
	ListTasks(status *Status, priority *Priority, category string, overdueOnly bool) ([]Task, error)
	SearchTasks(query string) ([]Task, error)
	CompleteTask(id int) error
	DeleteTask(id int) error
}

The implementation is fairly simple and just uses a slice of Task and linearly iterates them for list/search. I could have done something more sophisticated like using maps to store indices, but I kept it simple. My goal was just to learn Go, not to make the ideal TODO CLI.

There are a couple of things I do think are noteworthy about this design.

The getDueDate Function Pointer

The design doc examples showed being able to specify strings like "today" and "tomorrow" for the due date. I decided to capture these cases elsewhere in the code, so instead of having an API to specify "special" dates, the Manager just takes a function and passes its own now. This also makes testing pretty comfy.

Pointers for Status and Priority

The ListTasks API is kind of interesting because it accepts status/priority for "is equal to"-style filtering. There isn't a built-in Option type in Go, so I used pointers because their "zero" value is nil. This works really nicely with how structs use the zero value for unspecified fields. It means it I were to, for example, parse a "command" struct from the program arguments, I could leave the unspecified filtering arguments as nil very easily.

Speaking of, let's talk about the Command interface.

Creating the Commands

For my CLI commands, I created a very simple interface, it has a single Execute method which takes a Manager and returns (string, error). Here's what that looks like in code:

type Command interface {
	Execute(m tasks.Manager) (string, error)
}

Then, in the same cli.go file, I stubbed out a top level Parse function, with this signature:

func Parse(a *[]string) (Command, error) { ... }

I also stubbed out individual unexported helpers for parsing the individual commands. Commands more or less just delegate to Manager, but I do want to circle back one last time to AddCommand. The struct looks like this:

type AddCommand struct {
	Title    string
	Priority tasks.Priority
	Due      IntoDueDate
	Category string
}

The IntoDueDate type is a second interface I made:

type IntoDueDate interface {
	IntoDueDate(time.Time) tasks.DueDate
}

This is how I manage computing the timestamp for the Manager.Add API! Here's an example with DueTomorrow:

type DueTomorrow struct{}

func (d *DueTomorrow) IntoDueDate(t time.Time) tasks.DueDate {
	return tasks.DueDate(t.Add(time.Hour * 24))
}

Once I had all of the commands implemented, wiring everything up was easy!

Wiring it Up

One thing I really like about how this program turned out is just how simple everything is once it's all put together. The entire main method is very digestable and doesn't even require scrolling the page on my monitor:

func main() {
	args := os.Args[1:]
	cmd, err := cli.Parse(&args)
	if err != nil {
		fmt.Println("Error parsing command: ", err)
		os.Exit(1)
	}

	m, err := tasks.NewManager()
	if err != nil {
		fmt.Println("Error creating task manager: ", err)
		os.Exit(1)
	}

	res, err := cmd.Execute(m)
	if err != nil {
		fmt.Println("Error executing command: ", err)
		os.Exit(1)
	}

	fmt.Println(res)
}

Just like that, my first Go program was written!

Conclusion

My overall impression of Go is that it's a language written for getting things done quickly. The language feature set is small. Default behaviors are something you should be aware of and rely on. The standard library is robust enough to get you writing code ASAP.

What Go lacks in feature surface, it makes up for in having mostly sensible defaults. That being said, there were some things I found odd or surprising. Here's a couple of highlights of things I liked and things I didn't.

What I Liked

Most defaults are sensible: do you have an int? Your zero value is 0. A string is empty by default, so there's no need for a dedicated empty-checking function, just compare equality with "". Pointers are nil by default, so just be careful before trying to derefernce them. Most things behave the way you'd expect them to by default.

Table driven testing: I said earlier that I love TDD. Table-driven tests are, in my opinion, tragically underutilized. The fact that Go makes them so easy to write is a dream.

Extremely strong standard library: I mentioned earlier that the Go standard libary is really nice. I never needed to leave the standard library while building this app. While the app isn't perfect, it is completely usable and didn't take long to build. I didn't mention earlier, but there's even a text/tabwriter utility (link) for printing out tables. I used text/tabwriter for printing the list and search command results. Other honorable mentions not used in this project include slog for structured logging and a fully-featured template engine. That's just plain awesome.

What I Disliked

Date formatting: Go uses an example-based system for date format strings. Instead of yyyy-MM-dd, you say 2006-01-02, and therein lies the problem. Go by Example has some great, well, examples where you can see this in action. I stared at this reference time for a long, well, time. I eventually found out that this SO answer - as it turns out, the Go reference time is 1 2 3 4 5 6 7 in the form month day hour minute second year zone-offset. I really do not like this. It feels like an Easter egg hidden in the standard library. I don't mind the example-based format, but 1970-01-01 was already a perfectly decent epoch.

No Built-in Equals: Go does not allow types to redefine the behavior of ==. You have to write your own Equals method. I found this a bit frustrating when writing tests, but luckily there's reflect.DeepEqual, which uses reflection, so it shouldn't be used in Production code. I do still find it a bit strange though, given how ergonomic it is to opt-into things like custom JSON marshalling. This isn't something I'm losing any sleep over, though.

Final Thoughts

The obvious question is whether or not I'd use Go again. In short, yes, I would.

Depsite the small number of things I disliked, there was a lot to like. Go is small and fast, and that simplicity feels really nice. In the past I had a habit of prototyping an idea in Python and then actually implementing it for real in Rust or Java.

With Go, I feel like I can skip the Python step.