Go vs Kotlin for backend: two very different ways of building services
A comparison between Go and Kotlin for backend from real-world experience. Spring Boot/Ktor versus Go, typing, deployment and productivity.

I’ve been writing Kotlin for years. It’s the language I think in when designing a service, the one I use when I want a complex domain to be well modelled, and the one I’d choose if I had to maintain a monolith for a decade. When I started learning Go, the feeling was strange: like going back to a world where many of the things I take for granted simply don’t exist. And the surprising thing is that that isn’t always bad.
This comparison is not neutral. Kotlin is my main tool, and that means I know its virtues, its pitfalls and the moments when it saves you. But it also means I can be honest about when Go offers something that Kotlin doesn’t, or doesn’t give as easily.
Design philosophy: expressiveness vs intentional minimalism
The fundamental difference between Go and Kotlin is not in the syntax or the performance. It’s in something more subtle: in what each language decides not to give you. And that decision says more about the philosophy of the language than any feature list.
Kotlin inherits the tradition of expressive languages. It wants you to write less, for the code to read almost like prose, for the compiler to catch as many errors as possible before running anything. It offers null safety, extension functions, coroutines, sealed classes, data classes, delegated properties, smart casts… The list is long and each feature has its reason for being.
Go takes the opposite direction. Rob Pike and the Google team designed Go with a clear premise: simplicity is not the absence of features, it is a feature in itself. There is no inheritance, no exceptions, no generics with the power of Kotlin (though Go 1.18 added basic generics), no extension functions, no operator overloading. And that is deliberate.
Go doesn’t want you to write elegant code. It wants anyone on your team to be able to read and understand any file in the project in thirty seconds.
When you come from Kotlin, that feels restrictive. Almost frustrating, I’d say. But when you maintain a service written by someone else in Go, you start to understand the point. Not because Go is “better” than Kotlin. But because it solves the readability problem in a way that Kotlin, by design, delegates to team discipline.
Backend frameworks: Spring Boot/Ktor vs Go stdlib/Gin
But let’s leave the philosophy and get to the concrete, which is where these differences are really felt.
The Kotlin ecosystem
In Kotlin for backend you have two main paths:
- Spring Boot with Kotlin: the de facto standard in the corporate JVM world. Dependency injection, auto-configuration, a huge ecosystem of starters, integration with everything imaginable.
- Ktor: JetBrains’ native Kotlin framework. Lighter, coroutines-based, with a plugin model. Very pleasant to use, but with a smaller ecosystem.
A typical endpoint in Spring Boot with Kotlin:
@RestController
@RequestMapping("/api/users")
class UserController(
private val userService: UserService
) {
@GetMapping("/{id}")
suspend fun getUser(@PathVariable id: Long): ResponseEntity<UserDto> {
val user = userService.findById(id)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(user.toDto())
}
@PostMapping
suspend fun createUser(@RequestBody @Valid request: CreateUserRequest): ResponseEntity<UserDto> {
val user = userService.create(request)
return ResponseEntity.status(HttpStatus.CREATED).body(user.toDto())
}
}And the same in Ktor:
fun Route.userRoutes(userService: UserService) {
route("/api/users") {
get("/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest)
val user = userService.findById(id)
?: return@get call.respond(HttpStatusCode.NotFound)
call.respond(user.toDto())
}
post {
val request = call.receive<CreateUserRequest>()
val user = userService.create(request)
call.respond(HttpStatusCode.Created, user.toDto())
}
}
}The Go ecosystem
Go has a powerful standard library for HTTP. You don’t need a framework to build a functional API. But in practice, most projects use something like Gin, Chi or Echo to avoid reinventing routing.
The same endpoint in Go with Gin:
func (h *UserHandler) SetupRoutes(r *gin.Engine) {
users := r.Group("/api/users")
{
users.GET("/:id", h.GetUser)
users.POST("", h.CreateUser)
}
}
func (h *UserHandler) GetUser(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
user, err := h.userService.FindByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, toUserDTO(user))
}
func (h *UserHandler) CreateUser(c *gin.Context) {
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.userService.Create(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
return
}
c.JSON(http.StatusCreated, toUserDTO(user))
}And with the Go standard library (no framework):
mux := http.NewServeMux()
mux.HandleFunc("GET /api/users/{id}", h.GetUser)
mux.HandleFunc("POST /api/users", h.CreateUser)Direct comparison
| Aspect | Kotlin (Spring Boot) | Kotlin (Ktor) | Go (Gin/stdlib) |
|---|---|---|---|
| Learning curve | High (Spring is huge) | Medium | Low |
| Initial productivity | Medium (lots of config) | High | High |
| Implicit magic | Lots (DI, proxies, AOP) | Little | None |
| Library ecosystem | Huge (entire JVM world) | Growing | Sufficient |
| Cold start | Slow (JVM) | Slow (JVM) | Instant |
| Artifact size | Large (~50-200 MB) | Medium (~30-80 MB) | Small (~10-20 MB) |
In Spring Boot you need to know a lot to understand what’s really happening. In Go, what you see is what gets executed. Ktor sits at a middle point that I quite like.
Type system: two different worlds
This is where I have to be honest about my bias. Kotlin’s type system is one of its biggest selling points, and it’s the part of Kotlin I miss most when writing Go. I say it without reservation: if your domain is complex, Kotlin lets you model it in a way that Go simply cannot match.
Kotlin: types as domain documentation
sealed interface PaymentResult {
data class Success(val transactionId: String, val amount: Money) : PaymentResult
data class Declined(val reason: DeclineReason) : PaymentResult
data class Error(val exception: PaymentException) : PaymentResult
}
@JvmInline
value class Money(val cents: Long) {
init {
require(cents >= 0) { "Money cannot be negative" }
}
operator fun plus(other: Money) = Money(cents + other.cents)
}
enum class DeclineReason {
INSUFFICIENT_FUNDS, EXPIRED_CARD, FRAUD_SUSPECTED
}
// The when forces you to handle all cases
fun handlePayment(result: PaymentResult): String = when (result) {
is PaymentResult.Success -> "Payment ${result.transactionId} completed"
is PaymentResult.Declined -> "Declined: ${result.reason}"
is PaymentResult.Error -> "Error: ${result.exception.message}"
}This is real expressiveness. The compiler guarantees you handle all cases. Value classes prevent errors of the “passing a Long where another Long was expected” type. Sealed interfaces model finite states with associated data.
Go: simple types, explicit composition
type PaymentResult struct {
Status string
TransactionID string
Amount int64
DeclineReason string
Err error
}
func handlePayment(result PaymentResult) string {
switch result.Status {
case "success":
return fmt.Sprintf("Payment %s completed", result.TransactionID)
case "declined":
return fmt.Sprintf("Declined: %s", result.DeclineReason)
case "error":
return fmt.Sprintf("Error: %v", result.Err)
default:
return "Unknown status"
}
}The default in Go is revealing, and I think it summarises the difference in philosophies well. Kotlin forces you to handle all cases. Go doesn’t know what all the cases are, so you need that defensive default. If someone adds a new state, Kotlin’s compiler warns you everywhere it’s missing. In Go, the default silently swallows the case. Can you argue that’s a discipline problem rather than a language problem? Yes. But the reality is that compilers are better at being disciplined than humans are.
In complex domains (finance, health, logistics), Kotlin’s modelling capability is priceless. If your service is a CRUD with few business rules, the difference matters less.
Concurrency: coroutines vs goroutines
This is one of the most interesting comparisons, and the one that has made me reflect the most. Because both languages solve the same problem in a conceptually similar way, but very different in practice.
Kotlin coroutines
suspend fun fetchUserWithOrders(userId: Long): UserWithOrders {
return coroutineScope {
val userDeferred = async { userService.findById(userId) }
val ordersDeferred = async { orderService.findByUserId(userId) }
val user = userDeferred.await()
?: throw UserNotFoundException(userId)
val orders = ordersDeferred.await()
UserWithOrders(user, orders)
}
}
// Structured concurrency with timeout
suspend fun fetchWithTimeout(userId: Long): UserWithOrders {
return withTimeout(5.seconds) {
fetchUserWithOrders(userId)
}
}Goroutines in Go
func fetchUserWithOrders(ctx context.Context, userID int64) (*UserWithOrders, error) {
g, ctx := errgroup.WithContext(ctx)
var user *User
var orders []Order
g.Go(func() error {
var err error
user, err = userService.FindByID(ctx, userID)
return err
})
g.Go(func() error {
var err error
orders, err = orderService.FindByUserID(ctx, userID)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
return &UserWithOrders{User: user, Orders: orders}, nil
}Differences that matter
| Aspect | Kotlin coroutines | Go goroutines |
|---|---|---|
| Structured concurrency | Native (coroutineScope) | With errgroup or manual |
| Cancellation | Cooperative, propagated | Via context.Context |
| Learning curve | Medium-high (scopes, dispatchers) | Low (but easy to make mistakes) |
| Channels | Yes (Channel<T>) | Yes (part of the language) |
| Runtime weight | Depends on JVM | Very lightweight |
| Leaks | Structured concurrency prevents them | Easy to create goroutine leaks |
What I like most about Kotlin coroutines is structured concurrency: if the parent scope is cancelled, all children are automatically cancelled. In Go you have to be very disciplined with context and error propagation, or you end up with dangling goroutines that nobody cancels.
But goroutines have an advantage I can’t ignore: any function can launch a goroutine with go. You don’t need to mark functions as suspend, there’s no coloring problem, you don’t need to understand dispatchers or scopes. The barrier to entry is much lower. At what cost? That it’s easier to make mistakes without anyone warning you. It’s a trade-off, not a clear victory.
Error handling: sealed classes vs if err != nil
And here we come to the point I find hardest to treat with equanimity. This is probably the most polarising difference and the one that generates the most frustration when you come from an expressive language.
Kotlin: errors as types
sealed interface Result<out T> {
data class Ok<T>(val value: T) : Result<T>
data class Err(val error: AppError) : Result<Nothing>
}
sealed interface AppError {
data class NotFound(val entity: String, val id: String) : AppError
data class Validation(val field: String, val message: String) : AppError
data class Infrastructure(val cause: Throwable) : AppError
}
fun findUser(id: Long): Result<User> {
val user = userRepository.findById(id)
?: return Result.Err(AppError.NotFound("User", id.toString()))
return Result.Ok(user)
}
// In the handler
when (val result = findUser(id)) {
is Result.Ok -> respond(result.value)
is Result.Err -> when (result.error) {
is AppError.NotFound -> respond(HttpStatusCode.NotFound)
is AppError.Validation -> respond(HttpStatusCode.BadRequest)
is AppError.Infrastructure -> respond(HttpStatusCode.InternalServerError)
}
}Go: if err != nil, full stop
func (s *UserService) FindUser(ctx context.Context, id int64) (*User, error) {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("finding user %d: %w", id, err)
}
if user == nil {
return nil, ErrNotFound
}
return user, nil
}
// In the handler
user, err := userService.FindUser(ctx, id)
if err != nil {
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
c.JSON(http.StatusOK, toDTO(user))Let me be direct: Go’s error handling is verbose and repetitive. There’s no elegant way to say it. After writing your hundredth function with five if err != nil blocks, you deeply miss Kotlin’s exhaustive when, automatic error propagation, or even Java’s exceptions. Technically you’re not doing anything wrong, but the feeling is one of writing boilerplate.
But there is an argument in favour of Go that is hard to ignore: there is never a hidden error. Every failure point is visible. There are no exceptions flying through the stack without anyone catching them. There is no runCatching swallowing a critical error because someone didn’t think of that case.
The verbosity of Go in error handling is annoying. But forcing yourself to decide what to do at every failure point produces more robust code, even if it doesn’t look that way at first glance.
Deployment: JVM vs static binary
Changing tack, this is the section where Go wins categorically. And here there is not much to qualify.
Kotlin on JVM
FROM eclipse-temurin:21-jre-alpine
COPY build/libs/app.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar"]- You need a JRE in the image (or GraalVM for native).
- Spring Boot cold start can be 3 to 15 seconds.
- Base memory consumption is around 150-400 MB.
- The Docker image weighs between 150-300 MB.
Go
FROM scratch
COPY app /app
EXPOSE 8080
ENTRYPOINT ["/app"]Yes, FROM scratch. A statically compiled Go binary needs absolutely nothing. No base operating system, no runtime, no shared libraries.
- Starts in milliseconds.
- Base memory consumption of 5-20 MB.
- Docker image of 10-20 MB.
| Aspect | Kotlin (JVM) | Kotlin (GraalVM native) | Go |
|---|---|---|---|
| Start time | 3-15 s | 0.1-0.5 s | < 50 ms |
| Base memory | 150-400 MB | 50-100 MB | 5-20 MB |
| Docker image | 150-300 MB | 50-100 MB | 10-20 MB |
| Compilation | Fast | Very slow (minutes) | Fast |
| Production debugging | Good (JVM tools) | Limited | Good (pprof, delve) |
If you work with Kubernetes and need to scale quickly, the difference between 10 seconds of start time and 50 milliseconds is the difference between autoscaling that works and one that arrives too late.
GraalVM native image improves the situation quite a bit for Kotlin/Spring Boot, but compilation is slow, there are limitations with reflection (which Spring uses massively), and debugging gets complicated. I’ve tried GraalVM in real projects and the experience is… uneven. It’s a viable solution but with significant trade-offs worth experimenting with before committing heavily.
Readability and maintenance: beautiful vs predictable
Here comes my most direct personal experience, and the part where I’ve corrected myself the most over time.
Kotlin code can be beautiful. Extension functions, DSLs, custom operators, higher-order functions… you can write code that reads like a story. And I confess I love that. But that power has a cost I didn’t want to see at first: a junior developer or someone who doesn’t know Kotlin well can get lost reading a complex builder DSL or a chain of extension functions with implicit receivers.
// Idiomatic and expressive Kotlin
val report = users
.filter { it.isActive && it.registeredAfter(cutoffDate) }
.groupBy { it.department }
.mapValues { (_, users) ->
users.sumOf { it.totalPurchases }
}
.toSortedMap()
.also { log.info("Report generated for ${it.size} departments") }The equivalent in Go:
// Go: explicit and step by step
report := make(map[string]int64)
for _, user := range users {
if !user.IsActive || user.RegisteredAt.Before(cutoffDate) {
continue
}
report[user.Department] += user.TotalPurchases
}
keys := make([]string, 0, len(report))
for k := range report {
keys = append(keys, k)
}
sort.Strings(keys)
log.Printf("Report generated for %d departments", len(report))The Kotlin one is shorter and more declarative. The Go one is longer but absolutely explicit: you see every step, every decision, every iteration. No magic. No extension functions you have to go and look up. No lambdas with implicit receivers. Which is “better”? It depends on who you ask, and above all on who is going to maintain that code in two years.
In a team where everyone masters Kotlin, the first style is clearly better. In a heterogeneous team, or in an open source project where people with different levels contribute, Go has a real advantage: everyone reads Go the same way, because there aren’t many ways to write it.
Go is the language where the code you write in your first month looks a lot like what you write in your third year. Kotlin is the language where every year you discover a better way of doing things. Both have value.
Ecosystem and libraries
And this brings us to the ecosystem, which is where the conversation becomes more pragmatic. Kotlin has access to the entire JVM ecosystem. That’s decades of mature libraries, battle-tested in production by millions of applications:
- Hibernate/Exposed for ORM
- Jackson/kotlinx.serialization for JSON
- Spring Security for authentication
- Apache Kafka clients, Elasticsearch, Redis…
The list is endless. If a Java library exists, it works in Kotlin.
Go has a younger but surprisingly complete ecosystem for backend:
- GORM/sqlx/pgx for databases
- encoding/json in stdlib (and options like sonic for performance)
- Gin/Chi/Echo for HTTP
- Official Google Cloud clients, AWS SDK, etc.
Where Go falls short is in complex enterprise domains. There is no equivalent to Spring Security with the same depth. There is no ORM as complete as Hibernate — and many in the Go community argue that’s a virtue, which is an interesting debate but one that doesn’t solve your problem when you genuinely need a full ORM. Validation libraries are more basic.
| Domain | Kotlin/JVM | Go |
|---|---|---|
| HTTP/API | Excellent | Excellent |
| Databases | Excellent (Hibernate, Exposed) | Good (sqlx, pgx) |
| Messaging (Kafka, RabbitMQ) | Excellent | Good |
| Observability | Excellent (Micrometer, etc.) | Good (OpenTelemetry) |
| Machine Learning | Good (DL4J, Python interop) | Basic |
| CLI tools | Limited | Excellent |
| Infrastructure (Docker, K8s) | Not its strength | Native |
When Kotlin is the best choice
After working with both, I think I have a fairly clear idea of when Kotlin wins. Though I acknowledge my bias towards Kotlin may influence things here:
Complex domains with rich business logic. If your service manages states, business rules with many variants, complex workflows… sealed classes, the type system and Kotlin’s expressiveness will save you bugs and make the code much more maintainable.
Teams with JVM experience. If your team comes from Java, Kotlin is a direct improvement without changing the ecosystem. Spring Boot still works, the libraries you know are still there, but you write less and with more safety.
Projects that need the JVM ecosystem. If you depend on specific Java libraries (certain enterprise clients, reporting frameworks, integration with legacy systems), Kotlin on JVM is the obvious choice.
Android and multiplatform. Kotlin Multiplatform allows sharing logic between backend, Android, iOS and web. Go doesn’t play in this space.
When the correctness of the data model is critical. With Spring Boot and Kotlin, being able to model your domain with sealed interfaces and value classes eliminates an entire category of bugs.
When Go is the best choice
But the interesting question is another one: when does Go offer something that Kotlin doesn’t give as easily? And the answer surprised me more than I expected:
Simple microservices and REST APIs without complex logic. If your service receives requests, queries a database and returns JSON, Go lets you do it with minimal resource consumption and trivial deployment. For simple REST APIs, Go is hard to beat.
Infrastructure and system tools. Docker, Kubernetes, Terraform, Prometheus… are written in Go for good reasons. If you’re building DevOps tools, Go is the de facto standard.
CLIs. A static binary that works on any platform, without dependencies. Go is unbeatable for this.
When operational performance matters more than expressiveness. In environments where you need fast start-up, low memory consumption and lightweight deployments (edge computing, serverless functions with frequent cold starts), Go wins clearly.
Heterogeneous teams or with high turnover. Go’s simplicity reduces onboarding time. Any developer can be productive in Go in one or two weeks. Idiomatic Kotlin takes more time to master.
When you want clean architecture without magic. Go forces you to be explicit with dependencies, to pass everything as a parameter, to not rely on automatic injection. For some projects, that rigidity is exactly what you need.
Can you use both? Yes, and it’s a valid strategy
In the real world, the question “Go or Kotlin” doesn’t always have a single answer. And I think the maturity is in accepting that without getting frustrated. I know teams (and I myself consider it for certain projects) that use both:
- Kotlin for the core domain service, where business logic is complex and Kotlin’s type system protects against subtle errors.
- Go for auxiliary services: proxies, queue processing workers, infrastructure tools, gateway APIs.
This is not overengineering if done with judgement. Each language plays where it’s strongest. What matters is that the team has competence in both and that there are clear conventions about when to use each.
┌─────────────────────────────────────────────────┐
│ Mixed architecture │
├────────────────────┬────────────────────────────┤
│ Go │ Kotlin │
│ ────────────── │ ──────────────────── │
│ API Gateway │ Payment service │
│ Queue worker │ Rules engine │
│ Deploy CLI │ User service │
│ Metrics proxy │ Workflow orchestrator │
└────────────────────┴────────────────────────────┘What I’ve learned choosing between the two
I’m not going to pretend equidistance, because that would be dishonest. Kotlin remains my preferred language for backend. Its type system, its expressiveness and the JVM ecosystem make it unbeatable for projects with real complexity. When a domain has many states, many rules and needs to evolve for years, Kotlin gives me confidence that the code will hold up. For complex domains, rich data modelling, or when you already have a team with JVM experience, Kotlin is the choice that comes naturally.
But Go has taught me something I didn’t expect: the value of simplicity. It has forced me to question whether I really need that abstraction, that DSL, that sophisticated pattern. And being honest, more times than I’d like to admit the answer was no. When the answer is yes, Kotlin shines. But when it’s no, Go rewards you with a service that starts in milliseconds, consumes almost nothing, and that anyone can maintain. For lightweight microservices, CLIs, infra tools, or anything where fast start-up and low consumption matter, Go has shown me that less abstraction can mean more productivity.
If you’re starting with Go coming from Kotlin or Java, I recommend checking out the Go vs Java comparison which goes into more detail about the differences with the JVM world. And if you want to see what building something real in Go looks like, take a look at how to set up a REST API from scratch. The best tool depends on the problem. And the best sign of maturity as an engineer is not falling in love with any one of them.


