Reading
A reading list from the RSS feeds I follow (happy to get good recommendations)
See my feeds.yaml.
Antirez
- Not just development, distribution of software may change as wellJul 22, 2026
Even if you are as averse to semver as I used to be in the course of my programming activity, you can still think of open source software distribution as something that used to follow a fixed number of steps. There is a branch where developments happen, and this branch oftentimes happens to be not really ready for reliable work. Then you freeze the developments for a certain amount of time (even if, in the meantime, the work can continue on some new unstable branch), fix bugs, ask people to test it. At some point the number of bug reports starts to drop, your team and your users start to believe there are no longer obvious critical flaws that are easy to discover in the next few weeks: then you call the branch 2.4 or whatever, and that's it. However now, with AI coding, it's not just development that has changed, but also the act itself of using software is affected: it is not just you that can ask an AI to do certain changes to the software, but also the recipient of the software itself. This is obvious in the domains where a piece of software has its main user base among programmers, but this is also true in general, as more and more technologically inclined users have AI access and coding agents. Because of this change, the idea of just having a stable branch with everything polished, and an unstable branch where everything is a work in progress, may no longer be the right way to do things. A code repository can also be a finished product, but could be even more useful if it is a template for how to do things around a given problem. Maybe the user will modify the code in order to specialize it for a specific set of requirements, hardware, specific problems to solve. Also, what is too unstable or unproven for the general public may be the right thing for another set of users. Take the example of Redis. For weeks now I have been iterating on a PR that provides strong memory savings for sorted sets. This work, if accepted, will hit every user of Redis, from people that don't have any idea about how Redis works, to users that maybe even contributed code in the course of years. From use cases that are trivial to use cases where a 50% memory saving on sorted sets could mean cutting a big slice of the cloud bill every year. For this last kind of user, having the final product (after all the testing and changes of design I'm doing to refine something that "just works", with the risk that maybe it will not even enter the code base) may be less interesting than having a 95%-ready branch since day zero. It is code they can test, adapt, iterate on, even specialize more for the problem at hand. Maybe DwarfStar is an even more telling example of how code repositories should be good examples more than finished products covering every piece of the features matrix. With local inference you have, in the specific case of DwarfStar, many kinds of GPUs, models, server mode, agent mode, CLI, SSD streaming, tensor and pipeline distributed execution. To test everything everywhere is complicated. Yet, once you have two solid examples of tensor parallel graph execution, a strong coding agent can infer how to implement the same thing for other backend/model pairs. Similarly, once you have an engine that supports two models well enough, a third can be implemented in an almost automatic way, using the existing code base as a guardrail for coding agents in order to guide the implementation. This does not mean that a project like DwarfStar should not work out of the box, but that it could focus on supporting very well a set of features that can be extrapolated to a larger amount of possible situations that the users can cover themselves. It also means another thing: that main and unstable are no longer enough. Many experimental branches could be an integral part of the project. For instance, yesterday the Laguna S.1 model was released. It looks interesting on paper, however: will it really be good enough? Will the new DeepSeek v4 Flash checkpoints make it not really relevant for DwarfStar? It is too early to say. However, to collectively form an idea, publishing a branch with this model implementation is a good middle ground: people will try it, will refine it with their coding agents, and the community can collectively form an idea about how merge-worthy it is. Moreover, today I noticed how, thanks to the rails formed by the corpus of the code inside DwarfStar, the implementation was written in about two hours by GPT 5.6 Sol automatically. Implementing DS4 and GLM5.2 cost me a lot of steering, reading the model card and the details of the implementation of the attention of those models. Now it just worked. GPT 5.6 is more powerful but it also found a lot of good examples inside the existing source code. Software today is more malleable than ever. In some way this means that it can be released in a more fluid way. Also, it means that the documentation itself should not be just good for humans, but also for coding agents to understand how to change the system. How this will evolve exactly, and what the right point of balance between the different dimensions of stability, usability, and features will be, is not clear to me, but I believe we developers need to keep our eyes open to see where all this is headed. Comments
- Control the ideas, not the codeJul 13, 2026
Look at the past history of this blog. There are many blog posts about programming with AI, a few of them date back to January 2024 (like this: https://antirez.com/news/140). I’m a relatively well regarded programmer, after all. I don’t have the need to still be in the “loop” as a old man that seeks for relevance, I recently rejoined Redis, and now I also am developing a new open source software for local LLM inference that received a good welcome in the community. Why I keep doing this, of saying what people don’t want to hear? Why I keep announcing how future programming will be by default? Because I feel the urge of lowering the impact for people less prepared to the change than me, often younger than me, and that, unlikely me, didn’t see many of those things coming (In 2022 I published, before ChatGPT existed, a book preannouncing many things that now happened and other things that I believe *will* happen, so I feel like I can say this without sounding egocentric). So mine is a trick. People feel more and more programming is completely modified by AI and don’t know what they should do, if they can really start coding in a completely different way, without looking much at the code as their main output. They feel like they are betraying their own field. So my intention is to arrive and say “look at me, In can write code, you know, I’m not hiding behind AI: yet, things changed, it’s not your weakness, it’s not that you are AI-pilled. It is just that our field is evolving in an incredible *and* painful (but also joyful) direction”. This is why yesterday, on X, I said that I believe many programmers at this point have less impact they could have because they look at the code. I truly believe into that. And note that this does not mean to vibe code something just asking for the final product. The point is: if you control the ideas of your software, looking at the code itself is suboptimal and often pointless. For the following reasons: 1. You can now generate a lot of code, even *not* accounting for the LLM code verbosity (that is also effect of not being able to instruct them well, for most of the part). How are you supposed to review 5k lines of code every day? 2. LLMs are very good at writing locally optimal code, and are worse (but improving) with big ideas. What’s the point of scanning function by function, line by line? Instead you should prompt the design you have in mind, sometimes ask “how is exactly the design of that part? How does it work?”, and evaluate if it is the right model. It is much faster. 3. The working day is 8 hours. If you read the code, it is a tradeoff. You are doing less of what today is the most important part of your job, that is, asking yourself: what I’m doing with this software? What are the new directions I want to take? And also, think at new ideas, features, optimizations tricks. And doing a lot of QA. Controlling the ideas. Do you remember this phrasing from the Mythical Man Month? Well, a book from the 70s tells us more things about the current software era than many of the things that were said from 2000 to 2020. Why people that now protest against AI were not horrified by the state of software in the last decade? The level of slop we touched during recent years, before AI, is unbelievable. I’ll say you another thing. What is slop? With DwarfStar I implemented an inference for two LLMs (DeepSeek v4 and GLM 5.2) in a completely automated way, but: try it yourself, you will discover you can’t just say “implement XYZ” and see it working. You have to understand how things work, what is the best design, how to reach a certain level of performance. Then I compared the implementation, for correctness, to other systems, finding that other implementations sometimes contained more errors. I researched more, and found that the local inference world is full of subtle errors that accumulate and damage the model output, issues in the attention implementation causing performance slopes after the context is over a certain limit because indexed attention implementations are broken (do more work than they should, for instance), and so forth. This is the result of a domain that is very complicated to handle, fast changing, with models that are slightly different one from the other in the inference graph being released every day. It’s an unfair game for developers. Well: AI helps a lot with that. There are many domains where rigorous engineering (in the design side) and testing is *far* better than writing a GPU kernel by hand (or reading it). So are we sure most of that resistance it is not ideological? Matteo Collina yesterday asked me, in reply to my tweet: but didn’t you say that you check all the AI generated code for Redis? And this is a good question indeed. Yes, I do, but this is, at this point, something I *need* to do but that I believe to be mostly pointless, partially once GPT 5.5 was released, but now with Fable and GPT 5.6 Sol even more. Yes: I identify things that I don’t like how they are coded, but if I open other Redis files written by other Redis contributors there is *far worse*, and not since they are not good coders, but because it is a matter of taste. I write very clean code since I want it to be readable, so during the implementation of Redis Arrays I operated changes. I’m doing it again for the 50% memory saving optimization of Redis sorted sets, a PR that I’ll submit soon. But I do not feel this is useful anymore. Nobody should anymore look at this code, but only at the ideas the code contains. I continued to do it out of respect for users. Redis is at this point a commonly useful thing, and many programmers will open files and modify stuff by hand. But if I had my hands free, you know what I would do, instead? Use all the time that the review is taking me to do more QA, to think at the next optimization idea and apply it, and to use LLMs to write a DESIGN.md file where each data structure is described in human language, with the ideas it contains, the implementation tricks, the design. That, in the future, is going to be much more useful. Do you want to modify sorted sets? You open the file, read the design, then you own the ideas. You can open your agent and ask it what to do with the right mental model. This is a lot more useful than reviewing the code. Fable and GPT 5.6 reviews to the sorted sets memory saving are going to spot ways more errors and subtle race conditions that my review is going to uncover. Yet I’ll do it. But for the majority of software projects, all this does not make sense anymore. Focus on controlling the ideas, instead. Focus on quality, testing, and having an idea of the software you want to ship. The world changed and it is painful, but also full of opportunities to improve a software world that was already completely rotten. I have a doubt only regarding young programmers that don't have enough experience, and can't build a mental model. We don't know, yet, if they will require or not to understand very well how a given piece of code works, but I believe they should learn how to write programs. Yet, I'm not sure checking the LLM output is the right thing they should do. It may be a lot more useful if they learn some programming language and implement a small interpreter, a small database, an hash table and so forth. Reviewing some Javascript stuff of some web site for a customer? Hell, no, don't lose time with that shit. Comments
- A new era for software testingJun 07, 2026
Automatic programming dramatically speeds up writing software in certain use cases and in the right hands. In my experience the output does not reach the structural quality and economy of complexity of the best hand-written software. However, not all the software is stellar, and my feeling is that automatic programming surpasses most of the times (and if well managed) the quality of decently developed hand-written code. Yet, there is a tradeoff between quality and time, in the case of writing new software with AI. This tradeoff in certain projects I developed can be brutal, that is, completing projects that may take many months in a few weeks. However, there are domains where LLMs simply open new strictly more powerful ways to automate processes, without any compromise on quality. One of those domains is software QA and testing. Traditionally software is tested using test suites that are composed of locally-scoped tests and integration tests (think of Redis: one thing is testing if SET foo 10 will be matched by GET foo => 10, another thing is testing if replication works in this case). And then by QA passes that are usually manually executed, and that can capture holes in the runnable test suite. It is a known fact that covering all the lines of the code does not mean covering all the possible states. Moreover integration testing is structurally hard: there are a number of timing issues, setups, and certain quality outputs that can only be visually inspected and not automatically checked that leave a lot of testing opportunities not really exploited because of time or logistic constraints. LLMs offer a new way to do QA on top of the existing testing methodologies. The idea is to create a markdown file where an AI agent is asked to work as a QA engineer, performing a number of manual testings on the new release. For instance, in the case of DwarfStar (an inference engine for open weights LLMs) I use the following approach. In the markdown file, the agent is asked to check what are the new commits on top of the already released version of the software project. Then the model is told a list of things that should be performed, like: - Check that distributed inference works across MacBook A and MacBook B, making sure the output is coherent, the inference works with all the GGUF files we have in both the machines, ... - Make sure this release does not contain any speed regression. And so forth. Notably, in the speed regression part, I don't have to tell the agent what was the previous expected speed, as this is a moving target that changes with new releases and new optimizations. Similarly the integration test for distributed inference does not require many instructions, at the start of the file there are just SSH endpoints and the key to use, the paths, and so forth. The agent is asked to check the long list of QA activities *especially* in light of the added commits, starting with an inspection of the changes and with the identification of what could be affected, so that the QA pass specializes trying to find specific regressions. In the case of Redis Arrays, I used a similar methodology asking the agent to build a large array-based Redis application, to setup a production environment with replication and persistency, to simulate the usage of the application for days and with many users, checking if something was odd. Testing that uses these approaches may also move in the more psychological side of software quality, asking the agent to identify all the new features that may look surprising, not documented enough, or generally sloppy from the POV of the user. All things that needed to be executed manually before, and that most of the times were mostly skipped. I have the feeling that the introduction of automatic QA may raise the bar of quality for new releases of software, and maybe partially compensate for the lower quality of the code produced at high speed with the use of automatic programming. Comments
- Distributing LLM inference in DwarfStarMay 25, 2026
High end NVIDIA cards, and the server and power needed to run them, cost a lot of money, especially if you plan to reach enough VRAM to run massive models. The alternative, so far, has been Apple hardware, or the DGX Spark that, even if severely limited because of memory bandwidth, still allows to run LLMs prompt processing (prefill) fast enough. The Mac Studio provided up to 512GB unified memory, a solution with modest memory bandwidth (but much better than the Spark) and compute at a price that was, after all, given the current situation, relatively fair. For instance, with DwarfStar the Mac Studio M3 Ultra 512GB can run DeepSeek v4 PRO at 150 t/s prefill and ~10-13 t/s decoding, not great but at a level that is usable for certain use cases. Even 2-bit quantized, DeepSeek v4 PRO resists very well, like Flash at the same quantization (today I made PRO write a C compiler, I'll publish the video soon). I would not consider a trivial fact to run a frontier model at home, with a ~12k total spending. One could expect this to get better and better, but the situation at the horizon appears cloudy. There is almost zero hope that NVIDIA setups will get less expensive, and even a small company can’t afford to easily purchase and handle a small data center for local inference. At the same time the RAM shortage is making it not exactly likely that we will see a Mac Studio with an M5 Ultra, maybe 1.2T/s memory bandwidth and more compute (the M5 Max is already faster, compute wise, and has the Neural Accelerators inside each GPU core that help with certain models). So the current situation for local inference is that the best machine is probably a laptop. The M5 Max 128GB can run DeepSeek v4 Flash and Mimo V2.5, 2-bit quantized, at very decent prefill and decoding speeds. We are talking of ~500 t/s prefill and ~35-40t/s decoding speed, with a performance slope as the context size increases which is very acceptable. At the cost of 6-7k depending on the configuration, this is currently one of the best deals. If this is the situation, for local inference projects in general, and for DwarfStart in particular, looking at distributed inference starts to be interesting. What we can do if we have two, three, four MacBook M5 Max systems? Or two M3 Ultra with 512 GB of RAM? Traditionally there are two main systems to run distributed inference. One is to duplicate memory by loading 50% of the transformer layers in computer A, the remaining 50% on computer B, and running the inference in a sequential way. In this case there is to send just the activations around, that’s very simple conceptually, and with some micro-batching magic it is possible to not just duplicate the memory but even in theory to increase substantially the prompt processing speed (but not the decoding: for a single token generation you have to wait the first layers on machine A, the remaining layers on machine B, and so forth — but at least less heat will be produced so it is possible to use a sustained load), which is not bad at all. This means, for example, that the lucky ones that have two Mac Studio 512GB machines could run full size DeepSeek v4 PRO (even if even the 2-bit quants are running very, very well) and with micro-batching even enjoy a faster prefill. Another approach is, using Apple RDMA, to parallelize the execution across the two machines, a vertical split basically. For instance one could try to load the same 2 bit quants on machine A and B, so that both fit, and each side has *all* the routed experts. Then for each layer we could try to do the coordination needed in order to execute half the experts in machine A, half in machine B, and so forth (note that both machines have all the experts, so whatever the router says, we can send 50% of the computation to the other machine, and the activations are tiny). This is more viable for the PRO that has much larger routed experts, so the communication penalty is less sensible. But if this could be made to work well, is all to be seen. There is also tensor parallelism, you are thinking, right? But I bet this is not viable at all with the communication speed we have among two Apple computers, two DGX Spark and so forth (go read the speed of NVLink). The magic about the above two models is that you have to send very little data. Ok, so far I bet you are thinking, this is the same shit everybody knows about running LLMs in a parallel fashion, and indeed this is true. But this post was conceived to reach this exact point. What about if we could, instead, parallelize two Mac or DGX in a completely different way? Open weights models are now in a golden age, we have plenty and many are very powerful. In the 128GB 2-bit quants classes there are many interesting: Minimax M2.7, Mimo V2.5, DeepSeek v4 Flash, and a few more. At the same time it was recently noted that LLMs ensemble (https://arxiv.org/abs/2502.18036) is an understudied possibility that allows two models to run in a completely shared-nothing way in two different machines, to only combine the logits or select the best continuation at the end. There are different ways to do that, and it works even if the two models have different vocabularies: you can pick the continuation where the perplexity is lower (that is, pick the model which is more sure: it’s like a two experts MoE where the routing is implicit), and it is even possible to combine the logits (with some complexities given by the different vocabularies) and sample from there. More recent papers suggest that mixing the two techniques is the best approach. Anyway: these techniques seem to really work, models appear to do better than alone. It’s like if their knowledge is improved because each one brings his POV on what to say next. Maybe this is one of the most logical third approach to try, other than the first two. I really hope to find the time to play more with all that, in the next months. Comments
- Alternatives for the EDIT tool of LLM agentsMay 19, 2026
EDIT: of course this was already done in the past! I had little doubts but people just confirmed me about it on Twitter :) But, keep reading: the CRC32 compromise at the end is an interesting tradeoff, and this is a good discussion to have in general. Right now I'm working to an agent for my DS4 project. Local inference is token-poor, it's a battlefield where optimizations count. I was quite surprised by the fact the EDIT tool everybody is using right now forces the LLM to emit the old version of the text verbatim. This CAS (check and set) mode of operation, where I say EDIT old="foo" new="bar", is needed because there are often colliding edits (the user is editing as well, or checked out a different branch, and so forth) and because the LLM can just hallucinate that a given line had a given content. This means, basically, that just using line numbers is very fragile: to say, change line 22 with new="foobar" is not good. Yet I don't want my local LLM to throw away tokens rewriting the old text each time, also because certain times the old text has a lot of special chars and spaces that the model may get wrong; in this case the tool would fail, forcing the LLM to do the same edit again. So I (re)designed a tag-based EDIT tool that is still CAS style, but more tokens efficient. The READ and SEARCH tools return something like that: 10:Q8fA int count = 10; 11:rA3_ if (count > limit) { 12:Kq9z count = limit; 13:PX0b } So there are line numbers and tags. The tag is 4 chars, on average 2.5 LLM tokens, representing a checksum of the line. Now the LLM can edit like this: { "tool": "edit", "path": "/tmp/example.c", "line": 10, "tag": "Q8fA", "new": "int count = 11;" } Or, multi line, like this: { "tool": "edit", "path": "/tmp/example.c", "lines": "11:rA3_\n12:Kq9z\n13:PX0b", "new": "if (count > limit)\n return limit;" } The saving is significant especially when the agent is deleting big amounts of text, but also in the general case. However, there is some overhead due to the fact we have line numbers and tags. There are potential tradeoffs, maybe the tag should be 8 chars and include the line number in the hash, there is to check exactly collisions possibilities and tokenization to see how much this is a win, but I like the line:tag format as later the LLM is often able to exploit the line information in many ways, like to get ranges in successive tool calls. Maybe there are other ways to exploit the tag, too, like: is this line still dj4_? The interesting thing is that DeepSeek v4 Flash is able to use this tool in a very effective way, so apparently it is natural for it. And while I did't measure the exact savings I saw in the field that edits are much faster and even more reliable. The alternative to this is to return just the whole file CRC32 each time (basically the tag becomes a file tag, even for partial reads). So that we can only work with line numbers + CRC, the edit would just specify 11,12,13,14. Less tokens, of course. This forces, however, to recompute the CRC32 of the file each time, but for reasonably sized files this is cheap enough. But this approach has limits: we fail the edit even if *unrelated* changes happened, so there is a strong tradeoff at play. To be fair to the whole file method, there is to say that it allows to specify ranges as 10:23, which is a huge win. I have the feeling I can decide which is better only with enough practical evidence, by using ds4-agent over multiple sessions with the two systems. For now maybe a command line to switch the edit mode is the first right step to do. Comments
- A few words on DS4May 15, 2026
I didn’t expect DwarfStar 4 (https://github.com/antirez/ds4) to become so popular so fast. It is clear that there was a need for single-model integration focused local AI experience, and that a few things happened together: the release of a quasi-frontier model that is large and fast enough to change the game of local inference, and the fact that it works extremely well with an extremely asymmetric quants recipe of 2/8 bit, so that 96 or 128GB of RAM are enough to run it. And, of course: all the experience produced by the local AI movement in the latest years, that can be leveraged more promptly because of GPT 5.5 (otherwise you can’t build DS4 in one week — and even with all this help you need to know how to gently talk to LLMs). The last week was funny and also tiring, I worked 14 hours per day on average. My normal average is 4/6 since early Redis times, but the first few months of Redis were like that. So, what’s next? Is this a project that starts and ends with DeepSeek v4 Flash? Nope, the model can change over time. The space will be occupied, in my vision, by the best current open weights model that is *practically fast* on a high end Mac or “GPU in a box” gear (like the DGX Spark and other similar setups). I bet that the next contender is DeepSeek v4 Flash itself, in the new checkpoint that will be released and, hopefully, a version specifically tuned for coding, and who knows, other expert-variants (not in the sense of MoE experts) maybe. For local inference, to have a ds4-coding, ds4-legal, ds4-medical models make a lot of sense, after all. You just load what you need depending on the question. It is the first time since I play with local inference (I play with it since the start) that I find myself using a local model for serious stuff that I would normally ask to Claude / GPT. This, I think, is really a big thing. It is also the first time that using vector steering I can enjoy an experience where the LLM can be used with more freedom. DeepSeek v4 Flash is really an impressive model, no doubt about that. If you can imagine in your mind the small good local model experience as A, and the frontier model you use online as B, DS4 is a lot more B than A. I can’t wait for the new releases, honestly (btw, thank you DeepSeek). So, after those chaotic first days, I hope the project will focus on: quality benchmarks, potentially adding a coding agent that is also part of the project, a hardware setup here in my home that can run the CI test in order to ensure long term quality, more ports, and finally but as a very important point: distributed inference (both serial and parallel). For now, thank you for all the support: it was really appreciated :) AI is too critical to be just a provided service. Comments
- Redis array type: short story of a long developmentMay 04, 2026
I started working on the new Array data type for Redis in the first days of January. The PR landed the repository only now, so this code was cooked for four months. I worked at the implementation kinda part time (kinda because many weeks were actually full time, sometimes to detach yourself from the keyboard is complicated), and even before LLMs the implementation was likely something I could do in four months. What changed is that in the same time span, I was able to do a lot more. This is the short story of what happened. In the first month I just wrote the specification document. The rationale for the new data type, the C structures, the sparse representation used, the exact semantics of the array cursor for ring buffer and ARINSERT. I started writing for days a long specification by hand, then I paired with Opus initially, then GPT 5.3 was released and I switched all the design and development with Codex. Since then I use only GPT 5.x for system programming tasks. Thanks to AI, the specification evolved a lot, via back and forth of feedback, intellectual challenges about what was the best design, what was the right compromise, what was too engineered and what not. Starting from the second month, I started the implementation using automatic programming (auto coding if you prefer), constantly reviewing the developed code. Then I realized that the level of indirection I picked was wrong. I really wanted people to be able to do ARSET myarray 293842948324 foo and everything to still work without huge allocations. The two levels of directory + slices (sparse and dense) I had were not enough. Because I had AI, I took no compromises, and I decided to go the extra mile. Once certain conditions are reached, the data structure internally changes shape, and becomes a super directory of sliced dense directories, that also point to the actual array slices (4096 elements per slice, by default). This design provided still the internal "is actually an array" representation I wanted, and the memory characteristics I seeked, while being able, for ARSCAN, and ARPOP, to scan the existing arrays taking a time proportional to the existing elements and not to the range span. Then, it was time to read all the code, line by line. Everything was working, and this type has massive testing, thanks, again to AI, but still things that superficially work do not mean they are optimal. I found many small inefficiencies or design errors that I didn't want, so I started a process of manual and AI-assisted rewrite of many modules. When this stage was done, I started, during the third month, to stress test the implementation in many different ways. I started to be confident that it was really solid, useful, well designed. Then… it happened. While modeling different use cases to see if the data structure was comfortable to use, I started to put markdown files into Redis arrays. Because files are a very good match for it. At this point, as I was working for other goals with agents, I realized that I could have the skills markdown files centralized knowledge base that I needed, so from a need of mine I decided to implement ARGREP. But I wanted regular expressions, too. What library to pick? I ended up picking TRE (thanks Ville Laurikari!), because when you have regexp in Redis, you want to be sure that there are no pathological patterns in time or space. But TRE was very inefficient in a specific and extremely useful case, that is matching foo|bar|zap. So with the help of GPT I optimized it, fixed a few potential security issues, and extended the test. I had everything in place. You know what was the biggest realization of all that? For high quality system programming tasks you have to still be fully involved, but I ventured to a level of complexity that I would have otherwise skipped. AI provided the safety net for two things: certain massive tasks that are very tiring (like the 32 bit support that was added and tested later), and at the same time the virtual work force required to make sure there are no obvious bugs in complicated algorithms. To write the initial huge specification was the key to the successive work, as it was the key to review each single line of sparsearray.c and t_array.c and modifying everything was not a good fit. I didn't spend any word on the use cases as I tried to document the PR itself with a message where they are detailed: https://github.com/redis/redis/pull/15162 So it was not really useful to repeat myself here. Enough to say that I really believe it is about time for Redis to have a data type where the numerical index is part of the semantics. I hope the Array PR will be accepted soon, and that we can benefit from the new use cases it opens. Of course, feedback is welcomed. Thank you. Comments
- AI cybersecurity is not proof of workApr 16, 2026
The proof of work is the wrong analogy: finding hash collisions, while exponentially harder with N, is guaranteed to find, with enough work, some S so that H(S) satisfies N, so an asymmetry of resources used will see the side with more "work ability" eventually winning. But bugs are different: 1. Different LLMs executions take different branches, but eventually the possible branches based on the code possible states are saturated. 2. If we imagine sampling the model for a bug in a given code M times, with M large, eventually the cap becomes not "M" (because of saturated state of the code AND the LLM sampler meaningful paths), but "I", the model intelligence level. The OpenBSD SACK bug easily shows that: you can run an inferior model for an infinite number of tokens, and it will never realize(*) that the lack of validation of the start window, if put together with the integer overflow, then put together with the fact the branch where the node should never be NULL is entered regardless, will produce the bug. So, cyber security of tomorrow will not be like proof of work in the sense of "more GPU wins"; instead, better models, and faster access to such models, will win. * Don't trust who says that weak models can find the OpenBSD SACK bug. I tried it myself. What happens is that weak models hallucinate (sometimes causally hitting a real problem) that there is a lack of validation of the start of the window (which is in theory harmless because of the start BTW, this is why with this bug, the stronger the model you pick (but not enough to discover the true bug), the less likely it is it will claim there is a bug. Stronger models hallucinate less, so they can't see the problem in any side of the spectrum: the hallucination side of small models, and the real understanding side of Mythos. Comments
- GNU and the AI reimplementationsMar 08, 2026
Those who cannot remember the past are condemned to repeat it. A sentence that I never really liked, and what is happening with AI, about software projects reimplementations, shows all the limits of such an idea. Many people are protesting the fairness of rewriting existing projects using AI. But, a good portion of such people, during the 90s, were already in the field: they followed the final part (started in the ‘80s) of the deeds of Richard Stallman, when he and his followers were reimplementing the UNIX userspace for the GNU project. The same people that now are against AI rewrites, back then, cheered for the GNU project actions (rightly, from my point of view – I cheered too). Stallman is not just a programming genius, he is also the kind of person that has a broad vision across disciplines, and among other things he was well versed in the copyright nuances. He asked the other programmers to reimplement the UNIX userspace in a specific way. A way that would make each tool unique, recognizable, compared to the original copy. Either faster, or more feature rich, or scriptable; qualities that would serve two different goals: to make GNU Hurd better and, at the same time, to provide a protective layer against litigations. If somebody would claim that the GNU implementations were not limited to copying ideas and behaviours (which is legal), but “protected expressions” (that is, the source code verbatim), the added features and the deliberate push towards certain design directions would provide a counter argument that judges could understand. He also asked to always reimplement the behavior itself, avoiding watching the actual implementation, using specifications and the real world mechanic of the tool, as tested manually by executing it. Still, it is fair to guess that many of the people working at the GNU project likely were exposed or had access to the UNIX source code. When Linus reimplemented UNIX, writing the Linux kernel, the situation was somewhat more complicated, with an additional layer of indirection. He was exposed to UNIX just as a user, but, apparently, had no access to the source code of UNIX. On the other hand, he was massively exposed to the Minix source code (an implementation of UNIX, but using a microkernel), and to the book describing such implementation as well. But, in turn, when Tanenbaum wrote Minix, he did so after being massively exposed to the UNIX source code. So, SCO (during the IBM litigation) had a hard time trying to claim that Linux contained any protected expressions. Yet, when Linus used Minix as an inspiration, not only was he very familiar with something (Minix) implemented with knowledge of the UNIX code, but (more interestingly) the license of Minix was restrictive, it became open source only in 2000. Still, even in such a setup, Tanenbaum protested about the architecture (in the famous exchange), not about copyright infringement. So, we could reasonably assume Tanenbaum considered rewrites fair, even if Linus was exposed to Minix (and having himself followed a similar process when writing Minix). # What the copyright law really says To put all this in the right context, let’s zoom in on the copyright's actual perimeters: the law says you must not copy “protected expressions”. In the case of the software, a protected expression is the code as it is, with the same structure, variables, functions, exact mechanics of how specific things are done, unless they are known algorithms (standard quicksort or a binary search can be implemented in a very similar way and they will not be a violation). The problem is when the business logic of the programs matches perfectly, almost line by line, the original implementation. Otherwise, the copy is lawful and must not obey the original license, as long as it is pretty clear that the code is doing something similar but with code that is not cut & pasted or mechanically translated to some other language, or aesthetically modified just to look a bit different (look: this is exactly the kind of bad-faith maneuver a court will try to identify). I have the feeling that every competent programmer reading this post perfectly knows what a *reimplementation* is and how it looks. There will be inevitable similarities, but the code will be clearly not copied. If this is the legal setup, why do people care about clean room implementations? Well, the reality is: it is just an optimization in case of litigation, it makes it simpler to win in court, but being exposed to the original source code of some program, if the exposition is only used to gain knowledge about the ideas and behavior, is fine. Besides, we are all happy to have Linux today, and the GNU user space, together with many other open source projects that followed a similar path. I believe rules must be applied both when we agree with their ends, and when we don’t. # AI enters the scene So, reimplementations were always possible. What changes, now, is the fact they are brutally faster and cheaper to accomplish. In the past, you had to hire developers, or to be enthusiastic and passionate enough to create a reimplementation yourself, because of business aspirations or because you wanted to share it with the world at large. Now, you can start a coding agent and proceed in two ways: turn the implementation into a specification, and then in a new session ask the agent to reimplement it, possibly forcing specific qualities, like: make it faster, or make the implementation incredibly easy to follow and understand (that’s a good trick to end with an implementation very far from others, given the fact that a lot of code seems to be designed for the opposite goal), or more modular, or resolve this fundamental limitation of the original implementation: all hints that will make it much simpler to significantly diverge from the original design. LLMs, when used in this way, don’t produce copies of what they saw in the past, but yet at the end you can use an agent to verify carefully if there is any violation, and if any, replace the occurrences with novel code. Another, apparently less rigorous approach, but potentially very good in the real world, is to provide the source code itself, and ask the agent to reimplement it in a completely novel way, and use the source code both as specification and in order to drive the implementation as far as possible away from the code itself. Frontier LLMs are very capable, they can use something even to explicitly avoid copying it, and carefully try different implementation approaches. If you ever attempted something like the above, you know how the “uncompressed copy” really is an illusion: agents will write the software in a very “organic” way, committing errors, changing design many times because of limitations that become clear only later, starting with something small and adding features progressively, and often, during this already chaotic process, we massively steer their work with our prompts, hints, wishes. Many ideas are consolatory as they are false: the “uncompressed copy” is one of those. But still, now the process of rewriting is so simple to do, and many people are disturbed by this. There is a more fundamental truth here: the nature of software changed; the reimplementations under different licenses are just an instance of how such nature was transformed forever. Instead of combatting each manifestation of automatic programming, I believe it is better to build a new mental model, and adapt. # Beyond the law I believe that organized societies prosper if laws are followed, yet I do not blindly accept a rule just because it exists, and I question things based on my ethics: this is what allows individuals, societies, and the law itself to evolve. We must ask ourselves: is the copyright law ethically correct? Does the speed-up AI provides to an existing process, fundamentally change the process itself? One thing that allowed software to evolve much faster than most other human fields is the fact the discipline is less anchored to patents and protections (and this, in turn, is likely as it is because of a sharing culture around the software). If the copyright law were more stringent, we could likely not have what we have today. Is the protection of single individuals' interests and companies more important than the general evolution of human culture? I don’t think so, and, besides, the copyright law is a common playfield: the rules are the same for all. Moreover, it is not a stretch to say that despite a more relaxed approach, software remains one of the fields where it is simpler to make money; it does not look like the business side was impacted by the ability to reimplement things. Probably, the contrary is true: think of how many businesses were made possible by an open source software stack (not that OSS is mostly made of copies, but it definitely inherited many ideas about past systems). I believe, even with AI, those fundamental tensions remain all valid. Reimplementations are cheap to make, but this is the new playfield for all of us, and just reimplementing things in an automated fashion, without putting something novel inside, in terms of ideas, engineering, functionalities, will have modest value in the long run. What will matter is the exact way you create something: Is it well designed, interesting to use, supported, somewhat novel, fast, documented and useful? Moreover, this time the inbalance of force is in the right direction: big corporations always had the ability to spend obscene amounts of money in order to copy systems, provide them in a way that is irresistible for users (free, for many years, for instance, to later switch model) and position themselves as leaders of ideas they didn’t really invent. Now, small groups of individuals can do the same to big companies' software systems: they can compete on ideas now that a synthetic workforce is cheaper for many. # We stand on the shoulders of giants There is another fundamental idea that we all need to internalize. Software is created and evolved as an incremental continuous process, where each new innovation is building on what somebody else invented before us. We are all very quick to build something and believe we “own” it, which is correct, if we stop at the exact code we wrote. But we build things on top of work and ideas already done, and given that the current development of IT is due to the fundamental paradigm that makes ideas and behaviors not covered by copyright, we need to accept that reimplementations are a fair process. If they don’t contain any novelty, maybe they are a lazy effort? That’s possible, yet: they are fair, and nobody is violating anything. Yet, if we want to be good citizens of the ecosystem, we should try, when replicating some work, to also evolve it, invent something new: to specialize the implementation for a lower memory footprint, or to make it more useful in certain contexts, or less buggy: the Stallman way. In the case of AI, we are doing, almost collectively, the error of thinking that a technology is bad or good for software and humanity in isolation. AI can unlock a lot of good things in the field of open source software. Many passionate individuals write open source because they hate their day job, and want to make something they love, or they write open source because they want to be part of something bigger than economic interests. A lot of open source software is either written in the free time, or with severe constraints on the amount of people that are allocated for the project, or - even worse - with limiting conditions imposed by the companies paying for the developments. Now that code is every day less important than ideas, open source can be strongly accelerated by AI. The four hours allocated over the weekend will bring 10x the fruits, in the right hands (AI coding is not for everybody, as good coding and design is not for everybody). Linux device drivers can be implemented by automatically disassembling some proprietary blob, for instance. Or, what could be just a barely maintained library can turn into a project that can be well handled in a more reasonable amount of time. Before AI, we witnessed the commodification of software: less quality, focus only on the money, no care whatsoever for minimalism and respect for resources: just piles of mostly broken bloat. More hardware power, more bloat, less care. It was already going very badly. It is not obvious nor automatic that AI will make it worse, and the ability to reimplement other software systems is part of a bigger picture that may restore some interest and sanity in our field. Comments
- Redis patterns for codingMar 01, 2026
Here LLM and coding agents can find: 1. Exhaustive documentation about Redis commands and data types. 2. Patterns commonly used. 3. Configuration hints. 4. Algorithms that can be mounted using Redis commands. https://redis.antirez.com/ Some humans claim this documentation is actually useful for actual people, as well :) I'm posting this to make sure search engines will index it. Comments
- Implementing a clear room Z80 / ZX Spectrum emulator with Claude CodeFeb 24, 2026
Anthropic recently released a blog post with the description of an experiment in which the last version of Opus, the 4.6, was instructed to write a C compiler in Rust, in a “clean room” setup. The experiment methodology left me dubious about the kind of point they wanted to make. Why not provide the agent with the ISA documentation? Why Rust? Writing a C compiler is exactly a giant graph manipulation exercise: the kind of program that is harder to write in Rust. Also, in a clean room experiment, the agent should have access to all the information about well established computer science progresses related to optimizing compilers: there are a number of papers that could be easily synthesized in a number of markdown files. SSA, register allocation, instructions selection and scheduling. Those things needed to be researched *first*, as a prerequisite, and the implementation would still be “clean room”. Not allowing the agent to access the Internet, nor any other compiler source code, was certainly the right call. Less understandable is the almost-zero steering principle, but this is coherent with a certain kind of experiment, if the goal was showcasing the completely autonomous writing of a large project. Yet, we all know how this is not how coding agents are used in practice, most of the time. Who uses coding agents extensively knows very well how, even never touching the code, a few hits here and there completely changes the quality of the result. # The Z80 experiment I thought it was time to try a similar experiment myself, one that would take one or two hours at max, and that was compatible with my Claude Code Max plan: I decided to write a Z80 emulator, and then a ZX Spectrum emulator (and even more, a CP/M emulator, see later) in a condition that I believe makes a more sense as “clean room” setup. The result can be found here: https://github.com/antirez/ZOT. # The process I used 1. I wrote a markdown file with the specification of what I wanted to do. Just English, high level ideas about the scope of the Z80 emulator to implement. I said things like: it should execute a whole instruction at a time, not a single clock step, since this emulator must be runnable on things like an RP2350 or similarly limited hardware. The emulator should correctly track the clock cycles elapsed (and I specified we could use this feature later in order to implement the ZX Spectrum contention with ULA during memory accesses), provide memory access callbacks, and should emulate all the known official and unofficial instructions of the Z80. For the Spectrum implementation, performed as a successive step, I provided much more information in the markdown file, like, the kind of rendering I wanted in the RGB buffer, and how it needed to be optional so that embedded devices could render the scanlines directly as they transferred them to the ST77xx display (or similar), how it should be possible to interact with the I/O port to set the EAR bit to simulate cassette loading in a very authentic way, and many other desiderata I had about the emulator. This file also included the rules that the agent needed to follow, like: * Accessing the internet is prohibited, but you can use the specification and test vectors files I added inside ./z80-specs. * Code should be simple and clean, never over-complicate things. * Each solid progress should be committed in the git repository. * Before committing, you should test that what you produced is high quality and that it works. * Write a detailed test suite as you add more features. The test must be re-executed at every major change. * Code should be very well commented: things must be explained in terms that even people not well versed with certain Z80 or Spectrum internals details should understand. * Never stop for prompting, the user is away from the keyboard. * At the end of this file, create a work in progress log, where you note what you already did, what is missing. Always update this log. * Read this file again after each context compaction. 2. Then, I started a Claude Code session, and asked it to fetch all the useful documentation on the internet about the Z80 (later I did this for the Spectrum as well), and to extract only the useful factual information into markdown files. I also provided the binary files for the most ambitious test vectors for the Z80, the ZX Spectrum ROM, and a few other binaries that could be used to test if the emulator actually executed the code correctly. Once all this information was collected (it is part of the repository, so you can inspect what was produced) I completely removed the Claude Code session in order to make sure that no contamination with source code seen during the search was possible. 3. I started a new session, and asked it to check the specification markdown file, and to check all the documentation available, and start implementing the Z80 emulator. The rules were to never access the Internet for any reason (I supervised the agent while it was implementing the code, to make sure this didn’t happen), to never search the disk for similar source code, as this was a “clean room” implementation. 4. For the Z80 implementation, I did zero steering. For the Spectrum implementation I used extensive steering for implementing the TAP loading. More about my feedback to the agent later in this post. 5. As a final step, I copied the repository in /tmp, removed the “.git” repository files completely, started a new Claude Code (and Codex) session and claimed that the implementation was likely stolen or too strongly inspired from somebody else's work. The task was to check with all the major Z80 implementations if there was evidence of theft. The agents (both Codex and Claude Code), after extensive search, were not able to find any evidence of copyright issues. The only similar parts were about well established emulation patterns and things that are Z80 specific and can’t be made differently, the implementation looked distinct from all the other implementations in a significant way. # Results Claude Code worked for 20 or 30 minutes in total, and produced a Z80 emulator that was able to pass ZEXDOC and ZEXALL, in 1200 lines of very readable and well commented C code (1800 lines with comments and blank spaces). The agent was prompted zero times during the implementation, it acted absolutely alone. It never accessed the internet, and the process it used to implement the emulator was of continuous testing, interacting with the CP/M binaries implementing the ZEXDOC and ZEXALL, writing just the CP/M syscalls needed to produce the output on the screen. Multiple times it also used the Spectrum ROM and other binaries that were available, or binaries it created from scratch to see if the emulator was working correctly. In short: the implementation was performed in a very similar way to how a human programmer would do it, and not outputting a complete implementation from scratch “uncompressing” it from the weights. Instead, different classes of instructions were implemented incrementally, and there were bugs that were fixed via integration tests, debugging sessions, dumps, printf calls, and so forth. # Next step: the ZX Spectrum I repeated the process again. I instructed the documentation gathering session very accurately about the kind of details I wanted it to search on the internet, especially the ULA interactions with RAM access, the keyboard mapping, the I/O port, how the cassette tape worked and the kind of PWM encoding used, and how it was encoded into TAP or TZX files. As I said, this time the design notes were extensive since I wanted this emulator to be specifically designed for embedded systems, so only 48k emulation, optional framebuffer rendering, very little additional memory used (no big lookup tables for ULA/Z80 access contention), ROM not copied in the RAM to avoid using additional 16k of memory, but just referenced during the initialization (so we have just a copy in the executable), and so forth. The agent was able to create a very detailed documentation about the ZX Spectrum internals. I provided a few .z80 images of games, so that it could test the emulator in a real setup with real software. Again, I removed the session and started fresh. The agent started working and ended 10 minutes later, following a process that really fascinates me, and that probably you know very well: the fact is, you see the agent working using a number of diverse skills. It is expert in everything programming related, so as it was implementing the emulator, it could immediately write a detailed instrumentation code to “look” at what the Z80 was doing step by step, and how this changed the Spectrum emulation state. In this respect, I believe automatic programming to be already super-human, not in the sense it is currently capable of producing code that humans can’t produce, but in the concurrent usage of different programming languages, system programming techniques, DSP stuff, operating system tricks, math, and everything needed to reach the result in the most immediate way. When it was done, I asked it to write a simple SDL based integration example. The emulator was immediately able to run the Jetpac game without issues, with working sound, and very little CPU usage even on my slow Dell Linux machine (8% usage of a single core, including SDL rendering). Once the basic stuff was working, I wanted to load TAP files directly, simulating cassette loading. This was the first time the agent missed a few things, specifically about the timing the Spectrum loading routines expected, and here we are in the territory where LLMs start to perform less efficiently: they can’t easily run the SDL emulator and see the border changing as data is received and so forth. I asked Claude Code to do a refactoring so that zx_tick() could be called directly and was not part of zx_frame(), and to make zx_frame() a trivial wrapper. This way it was much simpler to sync EAR with what it expected, without callbacks or the wrong abstractions that it had implemented. After such change, a few minutes later the emulator could load a TAP file emulating the cassette without problems. This is how it works now: do { zx_set_ear(zx, tzx_update(&tape, zx->cpu.clocks)); } while (!zx_tick(zx, 0)); I continued prompting Claude Code in order to make the key bindings more useful and a few things more. # CP/M One thing that I found really interesting was the ability of the LLM to inspect the COM files for ZEXALL / ZEXCOM tests for the Z80, easily spot the CP/M syscalls that were used (a total of three), and implement them for the extended z80 test (executed by make fulltest). So, at this point, why not implement a full CP/M environment? Same process again, same good result in a matter of minutes. This time I interacted with it a bit more for the VT100 / ADM3 terminal escapes conversions, reported things not working in WordStar initially, and in a few minutes everything I tested was working well enough (but, there are fixes to do, like simulating a 2Mhz clock, right now it runs at full speed making CP/M games impossible to use). # What is the lesson here? The obvious lesson is: always provide your agents with design hints and extensive documentation about what they are going to do. Such documentation can be obtained by the agent itself. And, also, make sure the agent has a markdown file with the rules of how to perform the coding tasks, and a trace of what it is doing, that is updated and read again quite often. But those tricks, I believe, are quite clear to everybody that has worked extensively with automatic programming in the latest months. To think in terms of “what a human would need” is often the best bet, plus a few LLMs specific things, like the forgetting issue after context compaction, the continuous ability to verify it is on the right track, and so forth. Returning back to the Anthropic compiler attempt: one of the steps that the agent failed was the one that was more strongly related to the idea of memorization of what is in the pretraining set: the assembler. With extensive documentation, I can’t see any way Claude Code (and, even more, GPT5.3-codex, which is in my experience, for complex stuff, more capable) could fail at producing a working assembler, since it is quite a mechanical process. This is, I think, in contradiction with the idea that LLMs are memorizing the whole training set and uncompress what they have seen. LLMs can memorize certain over-represented documents and code, but while they can extract such verbatim parts of the code if prompted to do so, they don’t have a copy of everything they saw during the training set, nor they spontaneously emit copies of already seen code, in their normal operation. We mostly ask LLMs to create work that requires assembling different knowledge they possess, and the result is normally something that uses known techniques and patterns, but that is new code, not constituting a copy of some pre-existing code. It is worth noting, too, that humans often follow a less rigorous process compared to the clean room rules detailed in this blog post, that is: humans often download the code of different implementations related to what they are trying to accomplish, read them carefully, then try to avoid copying stuff verbatim but often times they take strong inspiration. This is a process that I find perfectly acceptable, but it is important to take in mind what happens in the reality of code written by humans. After all, information technology evolved so fast even thanks to this massive cross pollination effect. For all the above reasons, when I implement code using automatic programming, I don’t have problems releasing it MIT licensed, like I did with this Z80 project. In turn, this code base will constitute quality input for the next LLMs training, including open weights ones. # Next steps To make my experiment more compelling, one should try to implement a Z80 and ZX Spectrum emulator without providing any documentation to the agent, and then compare the result of the implementation. I didn’t find the time to do it, but it could be quite informative. Comments
- Automatic programmingJan 31, 2026
In my YouTube channel, for some time now I started to refer to the process of writing software using AI assistance (soon to become just "the process of writing software", I believe) with the term "Automatic Programming". In case you didn't notice, automatic programming produces vastly different results with the same LLMs depending on the human that is guiding the process with their intuition, design, continuous steering and idea of software. Please, stop saying "Claude vibe coded this software for me". Vibe coding is the process of generating software using AI without being part of the process at all. You describe what you want in very general terms, and the LLM will produce whatever happens to be the first idea/design/code it would spontaneously, given the training, the specific sampling that happened to dominate in that run, and so forth. The vibe coder will, at most, report things not working or not in line with what they expected. When the process is actual software production where you know what is going on, remember: it is the software *you* are producing. Moreover remember that the pre-training data, while not the only part where the LLM learns (RL has its big weight) was produced by humans, so we are not appropriating something else. We can pretend AI generated code is "ours", we have the right to do so. Pre-training is, actually, our collective gift that allows many individuals to do things they could otherwise never do, like if we are now linked in a collective mind, in a certain way. That said, if vibe coding is the process of producing software without much understanding of what is going on (which has a place, and democratizes software production, so it is totally ok with me), automatic programming is the process of producing software that attempts to be high quality and strictly following the producer's vision of the software (this vision is multi-level: can go from how to do, exactly, certain things, at a higher level, to stepping in and tell the AI how to write a certain function), with the help of AI assistance. Also a fundamental part of the process is, of course, *what* to do. I'm a programmer, and I use automatic programming. The code I generate in this way is mine. My code, my output, my production. I, and you, can be proud. If you are not completely convinced, think to Redis. In Redis there is not much technical novelty, especially at its start it was just a sum of basic data structures and networking code that every competent system programmer could write. So, why it became a very useful piece of software? Because of the ideas and visions it contained. Programming is now automatic, vision is not (yet). Comments
- Don't fall into the anti-AI hypeJan 11, 2026
I love writing software, line by line. It could be said that my career was a continuous effort to create software well written, minimal, where the human touch was the fundamental feature. I also hope for a society where the last are not forgotten. Moreover, I don't want AI to economically succeed, I don't care if the current economic system is subverted (I could be very happy, honestly, if it goes in the direction of a massive redistribution of wealth). But, I would not respect myself and my intelligence if my idea of software and society would impair my vision: facts are facts, and AI is going to change programming forever. In 2020 I left my job in order to write a novel about AI, universal basic income, a society that adapted to the automation of work facing many challenges. At the very end of 2024 I opened a YouTube channel focused on AI, its use in coding tasks, its potential social and economical effects. But while I recognized what was going to happen very early, I thought that we had more time before programming would be completely reshaped, at least a few years. I no longer believe this is the case. Recently, state of the art LLMs are able to complete large subtasks or medium size projects alone, almost unassisted, given a good set of hints about what the end result should be. The degree of success you'll get is related to the kind of programming you do (the more isolated, and the more textually representable, the better: system programming is particularly apt), and to your ability to create a mental representation of the problem to communicate to the LLM. But, in general, it is now clear that for most projects, writing the code yourself is no longer sensible, if not to have fun. In the past week, just prompting, and inspecting the code to provide guidance from time to time, in a few hours I did the following four tasks, in hours instead of weeks: 1. I modified my linenoise library to support UTF-8, and created a framework for line editing testing that uses an emulated terminal that is able to report what is getting displayed in each character cell. Something that I always wanted to do, but it was hard to justify the work needed just to test a side project of mine. But if you can just describe your idea, and it materializes in the code, things are very different. 2. I fixed transient failures in the Redis test. This is very annoying work, timing related issues, TCP deadlock conditions, and so forth. Claude Code iterated for all the time needed to reproduce it, inspected the state of the processes to understand what was happening, and fixed the bugs. 3. Yesterday I wanted a pure C library that would be able to do the inference of BERT like embedding models. Claude Code created it in 5 minutes. Same output and same speed (15% slower) than PyTorch. 700 lines of code. A Python tool to convert the GTE-small model. 4. In the past weeks I operated changes to Redis Streams internals. I had a design document for the work I did. I tried to give it to Claude Code and it reproduced my work in, like, 20 minutes or less (mostly because I'm slow at checking and authorizing to run the commands needed). It is simply impossible not to see the reality of what is happening. Writing code is no longer needed for the most part. It is now a lot more interesting to understand what to do, and how to do it (and, about this second part, LLMs are great partners, too). It does not matter if AI companies will not be able to get their money back and the stock market will crash. All that is irrelevant, in the long run. It does not matter if this or the other CEO of some unicorn is telling you something that is off putting, or absurd. Programming changed forever, anyway. How do I feel, about all the code I wrote that was ingested by LLMs? I feel great to be part of that, because I see this as a continuation of what I tried to do all my life: democratizing code, systems, knowledge. LLMs are going to help us to write better software, faster, and will allow small teams to have a chance to compete with bigger companies. The same thing open source software did in the 90s. However, this technology is far too important to be in the hands of a few companies. For now, you can do the pre-training better or not, you can do reinforcement learning in a much more effective way than others, but the open models, especially the ones produced in China, continue to compete (even if they are behind) with frontier models of closed labs. There is a sufficient democratization of AI, so far, even if imperfect. But: it is absolutely not obvious that it will be like that forever. I'm scared about the centralization. At the same time, I believe neural networks, at scale, are simply able to do incredible things, and that there is not enough "magic" inside current frontier AI for the other labs and teams not to catch up (otherwise it would be very hard to explain, for instance, why OpenAI, Anthropic and Google are so near in their results, for years now). As a programmer, I want to write more open source than ever, now. I want to improve certain repositories of mine abandoned for time concerns. I want to apply AI to my Redis workflow. Improve the Vector Sets implementation and then other data structures, like I'm doing with Streams now. But I'm worried for the folks that will get fired. It is not clear what the dynamic at play will be: will companies try to have more people, and to build more? Or will they try to cut salary costs, having fewer programmers that are better at prompting? And, there are other sectors where humans will become completely replaceable, I fear. What is the social solution, then? Innovation can't be taken back after all. I believe we should vote for governments that recognize what is happening, and are willing to support those who will remain jobless. And, the more people get fired, the more political pressure there will be to vote for those who will guarantee a certain degree of protection. But I also look forward to the good AI could bring: new progress in science, that could help lower the suffering of the human condition, which is not always happy. Anyway, back to programming. I have a single suggestion for you, my friend. Whatever you believe about what the Right Thing should be, you can't control it by refusing what is happening right now. Skipping AI is not going to help you or your career. Think about it. Test these new tools, with care, with weeks of work, not in a five minutes test where you can just reinforce your own beliefs. Find a way to multiply yourself, and if it does not work for you, try again every few months. Yes, maybe you think that you worked so hard to learn coding, and now machines are doing it for you. But what was the fire inside you, when you coded till night to see your project working? It was building. And now you can build more and better, if you find your way to use AI effectively. The fun is still there, untouched. Comments
- Reflections on AI at the end of 2025Dec 20, 2025
* For years, despite functional evidence and scientific hints accumulating, certain AI researchers continued to claim LLMs were stochastic parrots: probabilistic machines that would: 1. NOT have any representation about the meaning of the prompt. 2. NOT have any representation about what they were going to say. In 2025 finally almost everybody stopped saying so. * Chain of thought is now a fundamental way to improve LLM output. But, what is CoT? Why it improves output? I believe it is two things: 1. Sampling in the model representations (that is, a form of internal search). After information and concepts relevant to the prompt topic is in the context window, the model can better reply. 2. But if you mix this to reinforcement learning, the model also learns to put one token after the other (each token will change the model state) in order to converge to some useful reply. * The idea that scaling is limited to the number of tokens we have, is no longer true, because of reinforcement learning with verifiable rewards. We are still not at AlphaGo move 37 moment, but is this really impossible in the future? There are certain tasks, like improving a given program for speed, for instance, where in theory the model can continue to make progress with a very clear reward signal for a very long time. I believe improvements to RL applied to LLMs will be the next big thing in AI. * Programmers resistance to AI assisted programming has lowered considerably. Even if LLMs make mistakes, the ability of LLMs to deliver useful code and hints improved to the point most skeptics started to use LLMs anyway: now the return on the investment is acceptable for many more folks. The programming world is still split among who uses LLMs as colleagues (for instance, all my interaction is via the web interface of Gemini, Claude, …), and who uses LLMs as independent coding agents. * A few well known AI scientists believe that what happened with Transformers can happen again, and better, following different paths, and started to create teams, companies to investigate alternatives to Transformers and models with explicit symbolic representations or world models. I believe that LLMs are differentiable machine trained on a space able to approximate discrete reasoning steps, and it is not impossible they get us to AGI even without fundamentally new paradigms appearing. It is likely that AGI can be reached independently with many radically different architectures. * There is who says chain of thought changed LLMs nature fundamentally, and this is why they, in the past, claimed LLMs were very limited, and now are changing their mind. They say, because of CoT, LLMs are now a different thing. They are lying. It is still the same architecture with the same next token target, and the CoT is created exactly like that, token after token. * The ARC test today looks a lot less insurmountable than initially thought: there are small models optimized for the task at hand that perform decently well on ARC-AGI-1, and very large LLMs with extensive CoT achieving impressive results on ARC-AGI-2 with an architecture that, according to many folks, would not deliver such results. ARC, in some way, transitioned from being the anti-LLM test to a validation of LLMs. * The fundamental challenge in AI for the next 20 years is avoiding extinction. Comments
- Scaling HNSWsNov 11, 2025
I’m taking a few weeks of pause on my HNSWs developments (now working on some other data structure, news soon). At this point, the new type I added to Redis is stable and complete enough, it’s the perfect moment to reason about what I learned about HNSWs, and turn it into a blog post. That kind of brain dump that was so common pre-AI era, and now has become, maybe, a bit more rare. Well, after almost one year of thinking and implementing HNSWs and vector similarity stuff, it is time for some writing. However this is not going to be an intro on HNSWs: too many are present already. This is the “extra mile” instead. If you know HNSWs, I want to share with you my more “advanced” findings, especially in the context of making them fast enough to allow for a “Redis” experience: you know, Redis is designed for low latency and high performance, and HNSWs are kinda resistant to that, so there were challenges to expose HNSWs as an abstract data structure. This blog post will be split into several sections. Think of them as pages of the same book, different chapters of the same experience. Oh and, by the way, I already wrote and subsequently lost this blog post :D [long, sad story about MacOS and bad habits – I hadn’t lost something like that since the 90s, during blackouts], so here most of the problem will be to recall what I wrote a few days ago and, while I’m at it, to better rephrase what I didn’t like very much. ## A few words about the state of HNSW Before digging into the HNSWs internals and optimizations, I want to say a few things about HNSWs. The original paper introducing HNSWs is a great piece of computer science literature, and HNSWs are amazing data structures, but: I don’t believe they are the last word for searching, in a greedy way, for nearby vectors according to a distance function. The paper gives the feeling it lacks some “pieces”, almost like if the researchers, given six months more, had a lot more to explore and say. For instance, I modified the paper myself, extending it in order to support removal of entries, actual removals, not just tombstone deletions where the element is marked as gone and collected later: deleting items is totally missing from the paper. Similarly, there are, right now, efforts in order to really check if the “H” in the HNSWs is really needed, and if instead a flat data structure with just one layer would perform more or less the same (I hope I’ll cover more about this in the future: my feeling is that the truth is in the middle, and that it makes sense to modify the level selection function to just have levels greater than a given threshold). All this to say that, if you are into data structures research, I believe that a great area is to imagine evolutions and refinements of HNSWs, without getting trapped within the idea that the evolutions are only in the sense of: let’s do it, but for disk (see Microsoft efforts), or the like. Ok, enough with the premise, let’s go to the actual low level stuff :) ## Scaling memory Redis is an in-memory system, and both HNSWs and vectors have the unfortunate quality of being very space-hungry. There are three reasons for this: 1. HNSWs have a lot of pointers, like 16, 32 or more pointers (this is a tunable parameter of HNSWs) to neighbor nodes. 2. HNSWs have many levels, being a skiplist-alike data structure. This exacerbates the first problem. 3. HNSW’s satellite data is a vector of floating point numbers, so, in the vanilla case, 4 bytes per component, and normally you can have 300-3000 components, this is the usual range. So, what are the lessons learned here? There are folks that compress pointers, since it is very likely that many pointers (8 bytes in 64 bit systems) will have the highest four bytes all the same. This is smart, I didn’t implement it yet, because in Redis I need to go fast, and this is a tradeoff between space and time: but maybe it is worth it, maybe not. I’ll dig more. However, if you do the math, the fact that there are many layers is not *so* terrible as it looks. On average, the multiple layers per node make the situation worse by just ~1.3x (if the probability of level increase is 0.25 in the level selection function), since many nodes will be just at layer 0. But still 1.3 is more than 1, and if that “H” in HNSWs really is not *so* useful… [Spoiler, what I found is that the seek time if you have everything at layer 0 is greater, the main loop for the greedy search will start from less optimal places and it will eventually reach the right cluster, but will take more computation time. However this is just early results.] So here the *real* low hanging fruit is: vector quantization. What I found is that if you use 8 bit quantization what you get is an almost 4x speedup, a 4x reduction of your vectors (but not a 4x reduction of the whole node: the pointers are still there, and they take a lot of space), and a recall that is virtually the same in real world use cases. This is the reason why Redis Vector Sets use 8 bit quantization by default. You can specify, via VADD options, that you want full precision vectors or binary quantized vectors, where we just take the sign, but I’m skeptical about using both full size vectors and binary quantized vectors. Before talking about them, let’s see what kind of quantization I used for 8 bit. What I do is to compute the maximum absolute value of the component of each vector (so quantization is per-vector), then I use signed 8 bit values to represent the quant from -127 to 127. This is not as good as storing both min and max value, but it is faster when computing cosine similarity, since I can do this: /* Each vector is quantized from [-max_abs, +max_abs] to [-127, 127] * where range = 2*max_abs. */ const float scale_product = (range_a/127) * (range_b/127); Then I multiply things together in the integer domain with (actually in the code the main loop is unrolled and uses multiple accumulators, to make modern CPUs more busy) for (; i And finally we can return back to the floating point distance with: float dotf = dot0 * scale_product; Check the vectors_distance_q8() for more information, but I believe you got the idea: it is very simple to go from the integer quants domain to the unquantized dotproduct with trivial operations. So, 8 bit quantization is a great deal, and full precision was a *needed* feature, because there will be people doing things with vectors generated in a way where each small amount makes a difference (no, with learned vectors this is not the case…) but, why binary quantization? Because I wanted users to have a simple way to not waste space when their *original* information is already binary. Imagine you have a set of users and they have yes/no properties, and you want to find similar users, items, whatever. Well: this is where binary quantization should be used, it’s just, again, an option of the VADD command. ## Scaling speed: threading and locality Oh, you know, I have to tell you something about myself: I’m not a fan of threaded systems when it is possible to do a lot with a single core, and then use multiple cores in a shared-nothing architecture. But HNSWs are different. They are *slow*, and they are accessed almost always in read-only ways, at least in most use cases. For this reason, my Vector Sets implementation is fully threaded. Not just reads, even writes are partially threaded, and you may wonder how this is possible without it resulting in a mess, especially in a system like Redis, where keys can be accessed in different ways by the background saving process, the clients, and so forth. Well, to start, let’s focus on reads. What happens is that as long as nobody is writing in the data structure, we can spawn threads that do the greedy collection of near vectors and return back the results to the blocked client. However, my implementation of HNSWs was written from scratch, I mean, from the empty C file opened with vim, it has 0% of shared code with the two implementations most other systems use, so there are a few “novelties”. One of such different things is that in order to avoid re-visiting already visited nodes, I use an integer stored in each node that is called “epoch”, instead of using another data structure to mark (like, in a hash table) nodes already visited. This is quite slow, I believe. The epoch instead is local to the node, and the global data structure increments the epoch for each search. So in the context of each search, we are sure that we can find epochs that are just But with threads, there are multiple searches occurring at the same time! And, yep, what I needed was an array of epochs: typedef struct hnswNode { uint32_t level; /* Node's maximum level */ … many other stuff … uint64_t visited_epoch[HNSW_MAX_THREADS]; } That’s what you can read in hnsw.h. This is, again, a space-time tradeoff, and again time won against space. So, how was it possible to have threaded writes? The trick is that in HNSW inserts, a lot of time is spent looking for neighbors candidates. So writes are split into a reading-half and commit-half, only the second needs a write lock, and there are a few tricks to make sure that the candidates we accumulated during the first part are discarded if the HNSW changed in the meantime, and some nodes may no longer be valid. There is, however, another problem. What about the user deleting the key, while background threads are working on the value? For this scenario, we have a function that waits for background operations to return before actually reclaiming the object. With these tricks, it is easy to get 50k ops/sec on real world vector workloads, and these are numbers I got from redis-benchmark itself, with all the overhead involved. The raw numbers of the flat HNSW library itself are much higher. ## Scaling memory: reclaiming it properly Before talking about how to scale HNSWs into big use cases with multiple instances involved, and why Redis Vector Sets expose the actual data structure in the face of the user (I believe programmers are smart and don’t need babysitting, but it’s not *just* that), I want to go back and talk again about memory, because there is an interesting story to tell about this specific aspect. Most HNSWs implementations are not able to reclaim memory directly when you delete a node from the graph. I believe there are two main reasons for that: 1. People misunderstand the original HNSW paper in a specific way: they believe links can be NOT reciprocal among neighbors. And there is a specific reason why they think so. 2. The paper does not say anything about deletion of nodes and how to fix the graph after nodes go away and we get missing links in the “web” of connections. The first problem is a combination (I believe) of lack of clarity in the paper and the fact that, while implementing HNSWs, people face a specific problem: when inserting a new node, and good neighbors are searched among existing nodes, often the candidates already have the maximum number of outgoing links. What to do, in this case? The issue is often resolved by linking unidirectionally from the new node we are inserting to the candidates that are already “full” of outgoing links. However, when you need to delete a node, you can no longer resolve all its incoming links, so you can’t really reclaim memory. You mark it as deleted with a flag, and later sometimes there is some rebuilding of the graph to “garbage collect” stale nodes, sometimes memory is just leaked. So, to start, my implementation in Redis does things differently by forcing links to be bidirectional. If A links to B, B links to A. But, how to do so, given that A may be busy? Well, this gets into complicated territory but what happens is that heuristics are used in order to drop links from existing nodes, with other neighbors that are well connected, and if our node is a better candidate even for the target node, and if this is not true there are other ways to force a new node to have at least a minimal number of links, always trying to satisfy the small world property of the graph. This way, when Redis deletes a node from a Vector Set, it always has a way to remove all the pointers to it. However, what to do with the remaining nodes that now are missing a link? What I do is to create a distance matrix among them, in order to try to link the old node neighbors among them, trying to minimize the average distance. Basically for each pair of i,j nodes in our matrix, we calculate how good is their connection (how similar their vectors are) and how badly linking them affects the *remaining* possible pairs (since there could be elements left without good pairs, if we link two specific nodes). After we build this matrix of scores, we then proceed with a greedy pairing step. This works so well that you can build a large HNSW with millions of elements, later delete 95% of all your elements, and the remaining graph still has good recall and no isolated nodes and so forth. That is what I mean when I say that there is space in HNSWs for new papers to continue the work. ## Scaling HNSWs to multiple processes When I started to work at Redis Vector Sets, there was already a vector similarity implementation in Redis-land, specifically as an index type of RediSearch, and this is how most people think at HNSWs: a form of indexing of existing data. Yet I wanted to provide Redis with a new HNSW implementation exposed in a completely different way. Guess how? As a data structure, of course. And this tells a story about how Redis-shaped is my head after so many years, or maybe it was Redis-shaped since the start, and it is Redis that is shaped after my head, since I immediately envisioned how to design a Redis data structure that exposed HNSWs to the users, directly, and I was puzzled that the work with vectors in Redis was not performed exactly like that. At the same time, when I handed my design document to my colleagues at Redis, I can’t say that they immediately “saw” it as an obvious thing. My reasoning was: vectors are like scores in Redis Sorted Sets, except they are not scalar scores where you have a total order. Yet you can VADD, VREM, elements, and then you can call VSIM instead of ZRANGE in order to have *similar* elements. This made sense not just as an API, but I thought of HNSWs as strongly composable, and not linked to a specific use case (not specific to text embeddings, or image embeddings, or even *learned* embeddings necessarily). You do: VADD my_vector_set VALUES [… components …] my_element_string So whatever is in your components, Redis doesn't care, when you call VSIM it will report similar elements. But this also means that, if you have different vectors about the same use case split in different instances / keys, you can ask VSIM for the same query vector into all the instances, and add the WITHSCORES option (that returns the cosine distance) and merge the results client-side, and you have magically scaled your hundred of millions of vectors into multiple instances, splitting your dataset N times [One interesting thing about such a use case is that you can query the N instances in parallel using multiplexing, if your client library is smart enough]. Another very notable thing about HNSWs exposed in this raw way, is that you can finally scale writes very easily. Just hash your element modulo N, and target the resulting Redis key/instance. Multiple instances can absorb the (slow, but still fast for HNSW standards) writes at the same time, parallelizing an otherwise very slow process. This way of exposing HNSWs also scales down in a very significant way: sometimes you want an HNSW for each user / item / product / whatever you are working with. This is very hard to model if you have an index on top of something, but it is trivial if your HNSWs are data structures. You just can have a Vector Set key for each of your items, with just a handful of elements. And of course, like with any other Redis key, you can set an expiration time on the key, so that it will be removed automatically later. All this can be condensed into a rule that I believe should be more present in our industry: many programmers are smart, and if instead of creating a magic system they have no access to, you show them the data structure, the tradeoffs, they can build more things, and model their use cases in specific ways. And your system will be simpler, too. ## Scaling loading times If I don’t use threading, my HNSW library can add word2vec (300 components for each vector) into an HNSW at 5000 elements/second if I use a single thread, and can query the resulting HNSW at 90k queries per second. As you can see there is a large gap. This means that loading back an HNSW with many millions of elements from a Redis dump file into memory would take a lot of time. And this time would impact replication as well. Not great. But, this is true only if we add elements from the disk to the memory in the most trivial way, that is storing “element,vector” on disk and then trying to rebuild the HNSW in memory. There is another lesson to learn here. When you use HNSWs, you need to serialize the nodes and the neighbors as they are, so you can rebuild everything in memory just allocating stuff and turning neighbors IDs into pointers. This resulted in a 100x speedup. But do you really believe the story ends here? Hehe. Recently Redis has stronger security features and avoids doing bad things even when the RDB file is corrupted by an attacker. So what I needed to do was to make sure the HNSW is valid after loading, regardless of the errors and corruption in the serialized data structure. This involved many tricks, but I want to take the freedom to just dump one comment I wrote here, as I believe the reciprocal check is particularly cool: /* Second pass: fix pointers of all the neighbors links. * As we scan and fix the links, we also compute the accumulator * register "reciprocal", that is used in order to guarantee that all * the links are reciprocal. * * This is how it works, we hash (using a strong hash function) the * following key for each link that we see from A to B (or vice versa): * * hash(salt || A || B || link-level) * * We always sort A and B, so the same link from A to B and from B to A * will hash the same. Then we xor the result into the 128 bit accumulator. * If each link has its own backlink, the accumulator is guaranteed to * be zero at the end. * * Collisions are extremely unlikely to happen, and an external attacker * can't easily control the hash function output, since the salt is * unknown, and also there would be to control the pointers. * * This algorithm is O(1) for each node so it is basically free for * us, as we scan the list of nodes, and runs on constant and very * small memory. */ ## Scaling use cases: JSON filters I remember the day when the first working implementation of Vector Sets felt complete. Everything worked as expected and it was the starting point to start with the refinements and the extra features. However in the past weeks and months I internally received the feedback that most use cases need some form of mixed search: you want near vectors to a given query vector (like most similar movies to something) but also with some kind of filtering (only released between 2000 and 2010). My feeling is that you need to query for different parameters less often than product people believe, and that most of the time you can obtain this more efficiently by adding, in this specific case, each year to a different vector set key (this is another instance of the composability of HNSWs expressed as data structures versus a kind of index). However I was thinking about the main loop of the HNSW greedy search, that is something like this: // Simplified HNSW greedy search algorithm. Don’t trust it too much. while(candidates.len() > 0) { c = candidates.pop_nearest(query); worst_distance = results.get_worst_dist(query); if (distance(query,c) > worst_distance) break; foreach (neighbor from c) { if (neighbor.already_visited()) continue; neighbor.mark_as_visited(); if (results.has_space() OR neighbor.distance(query) candidates.add(neighbor); results.add(neighbor); } } } return results; So I started to play with the idea of adding a JSON set of metadata for each node. What if, once I have things like {“year”: 1999}, this was enough to filter while I perform the greedy search? Sure, the search needed to be bound, but there is a key insight here: I want, to start, elements that are *near* to the query vector, so I don’t really need to explore the whole graph if the condition on the JSON attributes is not satisfied by many nodes. I’ll let the user specify the effort, and anyway very far away results that match the filter are useless. So that’s yet another way how my HNSW differs: it supports filtering by expressions similar to the ones you could write inside an “if” statement of a programming language. And your elements in the Vector Set can be associated with JSON blobs, expressing their properties. Then you can do things like: VSIM movies VALUES … your vector components here… FILTER '.year >= 1980 and .year ## A few words on memory usage HNSW’s fatal issue is — in theory — that they are normally served from memory. Actually, you can implement HNSWs on disk, even if there are better data structures from the point of view of disk access latencies. However, in the specific case of Redis and Vector Sets the idea is to provide something that is very fast, easy to work with: the flexibility of in-memory data structures help with that. So the question boils down to: is the memory usage really so bad? Loading the 3 million Word2Vec entries into Redis with the default int8 quantization takes 3GB of RAM, 1kb for each entry. Many use cases have just a few tens of million of entries, or a lot less. And what you get back from HNSWs, if well implemented, and in memory, is very good performance, which is crucial in a data structure and in a workload that is in itself slow by definition. In my MacBook I get 48k ops per second with redis-benchmark and VSIM against this key (holding the word2vec dataset). My feeling is that the memory usage of in-memory HNSWs is very acceptable for many use cases. And even in the use cases where you want the bulk of your vectors on disk, even if there is to pay for slower performance, your hot set should likely be served from RAM. This is one of the reasons why I believe that, to be active in HNSW research is a good idea: I don’t think they will be replaced anytime soon for most use cases. It seems more likely that we will continue to have different data structures that are ideal for RAM and for disk depending on the use cases and data size. Moreover, what I saw recently, even just scanning the Hacker News front page, is people with a few millions of items fighting with systems that are slower or more complicated than needed. HNSWs and carefully exposing them in the right way can avoid all that. ## Conclusions I like HNSWs, and working and implementing them was a real pleasure. I believe vectors are a great fit for Redis, even in an AI-less world (for instance, a few months ago I used them in order to fingerprint Hacker News users, replicating an old work published on HN in the past). HNSWs are simply too cool and powerful for a number of use cases, and with AI, and learned embeddings, all this escalates to a myriad of potential use cases. However, like most features in Redis, I expect that a lot of time will pass before people realize they are useful and powerful and how to use them (no, it’s not just a matter of RAG). This happened also with Streams: finally there is mass adoption, after so many years. If instead you are more interested in HNSW and the implementation I wrote, I believe the code is quite accessible, and heavily commented: https://github.com/redis/redis/blob/unstable/modules/vector-sets/hnsw.c If you want to learn more about Redis Vector Sets, please feel free to read the README file I wrote myself. There is also the official Redis documentation, but I suggest you start from here: https://github.com/redis/redis/tree/unstable/modules/vector-sets Thanks for reading such a long blog post! And have a nice day. References. This is the paper about the "H" in HNSW and how useful it is -> https://arxiv.org/abs/2412.01940 Comments
- AI is differentAug 13, 2025
Regardless of their flaws, AI systems continue to impress with their ability to replicate certain human skills. Even if imperfect, such systems were a few years ago science fiction. It was not even clear that we were so near to create machines that could understand the human language, write programs, and find bugs in a complex code base: bugs that escaped the code review of a competent programmer. Since LLMs and in general deep models are poorly understood, and even the most prominent experts in the field failed miserably again and again to modulate the expectations (with incredible errors on both sides: of reducing or magnifying what was near to come), it is hard to tell what will come next. But even before the Transformer architecture, we were seeing incredible progress for many years, and so far there is no clear sign that the future will not hold more. After all, a plateau of the current systems is possible and very credible, but it would likely stimulate, at this point, massive research efforts in the next step of architectures. However, if AI avoids plateauing long enough to become significantly more useful and independent of humans, this revolution is going to be very unlike the past ones. Yet the economic markets are reacting as if they were governed by stochastic parrots. Their pattern matching wants that previous technologies booms created more business opportunities, so investors are polarized to think the same will happen with AI. But this is not the only possible outcome. We are not there, yet, but if AI could replace a sizable amount of workers, the economic system will be put to a very hard test. Moreover, companies could be less willing to pay for services that their internal AIs can handle or build from scratch. Nor is it possible to imagine a system where a few mega companies are the only providers of intelligence: either AI will be eventually a commodity, or the governments would do something, in such an odd economic setup (a setup where a single industry completely dominates all the others). The future may reduce the economic prosperity and push humanity to switch to some different economic system (maybe a better system). Markets don’t want to accept that, so far, and even if the economic forecasts are cloudy, wars are destabilizing the world, the AI timings are hard to guess, regardless of all that stocks continue to go up. But stocks are insignificant in the vast perspective of human history, and even systems that lasted a lot more than our current institutions eventually were eradicated by fundamental changes in the society and in the human knowledge. AI could be such a change. Comments
- Coding with LLMs in the summer of 2025 (an update)Jul 20, 2025
Frontier LLMs such as Gemini 2.5 PRO, with their vast understanding of many topics and their ability to grasp thousands of lines of code in a few seconds, are able to extend and amplify the programmer capabilities. If you are able to describe problems in a clear way and, if you are able to accept the back and forth needed in order to work with LLMs, you can reach incredible results such as: 1. Eliminating bugs you introduced in your code before it ever hits any user: I experienced this with Vector Sets implementation of Redis. I would end eliminating all the bugs eventually, but many were just removed immediately by Gemini / Claude code reviews. 2. Explore faster how a given idea could work, by letting the LLM write the throw away code to test ASAP in order to see if a given solution is actually more performant, if it is good enough, and so forth. 3. Engage in pair-design activities where your instinct, experience, design taste can be mixed with the PhD-level knowledge encoded inside the LLM. In this activity, the LLM will sometimes propose stupid paths, other times incredibly bright ideas: you, the human, are there in order to escape local minimal and mistakes, and exploit the fact your digital friend knows of certain and various things more than any human can. 4. Accelerate your work by writing part of the code under your clear specifications. 5. Work with technologies far from your expertise but contiguous with what you can do (for instance: coding in 68000 assembly for an Amiga demo?) using LLMs as an extension of specific parts of your mind, for the knowledge you don't have. One and half years ago I wrote a blog post called “LLMs and programming in the first days of 2024”. There, I found LLMs to be already useful, but during these 1.5 years, the progresses they made completely changed the game. However, in order to leverage their capabilities, humans interacting with LLMs must have certain qualities and follow certain practices. Let’s explore them. ## Refuse vibe coding most of the times In this historical moment, LLMs are good amplifiers and bad one-man-band workers. There are still small throwaway projects where letting the LLM write all the code makes sense, like tests, small utilities of a few hundreds lines of codes. But while LLMs can write part of a code base with success (under your strict supervision, see later), and produce a very sensible speedup in development (or, the ability to develop more/better in the same time used in the past — which is what I do), when left alone with nontrivial goals they tend to produce fragile code bases that are larger than needed, complex, full of local minima choices, suboptimal in many ways. Moreover they just fail completely when the task at hand is more complex than a given level. Tomorrow all this may change, but right now after daily experience writing code with LLMs I strongly believe the maximum quality of work is reached using the human+LLM equation. I believe that humans and LLMs together are more productive than just humans, but this requires a big “if”, that is, if such humans have extensive communication capabilities and LLMs experiences: the ability to communicate efficiently is a key factor in using LLMs. ## Provide large context When your goal is to reason with an LLM about implementing or fixing some code, you need to provide extensive information to the LLM: papers, big parts of the target code base (all the code base if possible, unless this is going to make the context window so large than the LLM performances will be impaired). And a brain dump of all your understanding of what should be done. Such braindump must contain especially the following: * Hints about bad solutions that may look good, and why they could be suboptimal. * Hints about very good potential solutions, even if not totally elaborated by the humans still: LLMs can often use them in order to find the right path. * Clear goals of what should be done, the invariants we require, and even the style the code should have. For instance, LLMs tend to write Python code that is full of unnecessary dependencies, but prompting may help reducing this problem. C code tends to be, in my experience, much better. When dealing with specific technologies that are not so widespread / obvious, it is often a good idea to also add the documentation in the context window. For example when writing tests for vector sets, a Redis data type so new that LLMs don’t yet know about, I add the README file in the context: with such trivial trick, the LLM can use vector sets at expert level immediately. ## Use the right LLMs The most famous LLMs are not the best. Coding activities should be performed mostly with: * Gemini 2.5 PRO * Claude Opus 4 Gemini 2.5 PRO is, in my experience, semantically more powerful. Can spot more complex bugs, reason about more complex problems. Claude Opus may be better at writing new code sometimes (sometimes not), the user interface is more pleasant, and in general you need at least two LLMs to do some back and forth for complex problems in order to enlarge your (human) understanding of the design space. If you can pick just one, go for Gemini 2.5 PRO. The fundamental requirement for the LLM to be used is: don’t use agents or things like editor with integrated coding agents. You want to: * Always show things to the most able model, the frontier LLM itself. * Avoid any RAG that will show only part of the code / context to the LLM. This destroys LLMs performance. You must be in control of what the LLM can see when providing a reply. * Always be part of the loop by moving code by hand from your terminal to the LLM web interface: this guarantees that you follow every process. You are still the coder, but augmented. ## Conclusions Despite the large interest in agents that can code alone, right now you can maximize your impact as a software developer by using LLMs in an explicit way, staying in the loop. This will inevitably change in the future, as AI will improve, and eventually many coding tasks will be better served by AI alone: in this future, the human will decide the what & how, which is still crucial. But we are not yet there. In this exact moment taking control allows to use LLMs to produce the sharpest code possible: minimal when needed, using complex ideas when required. You will be able to do things that are otherwise at the borders of your knowledge / expertise while learning much in the process (yes, you can learn from LLMs, as you can learn from books or colleagues: it is one of the forms of education possible, a new one). Yet, everything produced will follow your idea of code and product, and will be of high quality and will not random fail because of errors and shortcomings introduced by the LLM. You will also retain a strong understanding of all the code written and its design. From time to time, it is wise to test what agents can do. But each time you feel they can’t do as well as you can, return to your terminal, and code with the help of AI (when you feel it can improve your output; there are times where you just are better alone). When this will be true, that agents will perform superb work, I’ll be the first to switch, and I’ll keep coding by myself just for passion. But for now, let’s skip the hype, and use AI at its best, that is: retaining control. There is another risk, however: of avoiding LLMs for some ideological or psychological refusal, accumulating a disadvantages (and failing to develop a large set of skills - hard to describe - needed to work with LLMs). Maybe this is really a case of "In medio stat virtus". Comments
- Human coders are still better than LLMsMay 29, 2025
This is a short story of how humans are still so much more capable of LLMs. Note that I'm not anti-AI or alike, you know it if you know me / follow me somewhere. I use LLMs routinely, like I did today, when I want to test my ideas, for code reviews, to understand if there are better approaches than what I had in mind, to explore stuff at the limit of my expertise, and so forth (I wrote a blog post about coding with LLMs almost two years, when it was not exactly cool: I was already using LLMs for coding and never stopped, I'll have to write an update, but that's not the topic of this post). But, still: the current level of AI is useful, great too, but so incredibly behind human intelligence, and I want to remark this as lately it is impossible to have balanced conversations. So, today I was working to Vector Sets for Redis, to fix a complicated bug: during the time I stopped working at Redis my colleagues introduced resistance against corruption RDB and RESTORE payloads, even when the checksum of the data passes. This feature is disabled by default, but provides an enhanced layer of safety for people wanting it. But… there is a but as big as an elephant: In order to make HNSWs fast to save into Redis RDBs and to load back, I serialized the *graph* representation, and not the element-vector pairs, otherwise I would have to re-insert back data into HNSWs, and that would be, like, 100 times slower (!). So I store all the links the nodes have with other nodes, as integers, and then I resolve them into pointers, it’s a nice trick and works great. But if you mix this and random corruptions of the representation, and the fact that my own twist on HNSWs enforce reciprocal links between nodes (I wrote my own implementation of HNSWs with many useful features, but reciprocal links are needed to enable many of them) then this could happen: 1. We load corrupted data that says A links to B, but B no longer links to A (corrupted node IDs). 2. We delete node B: since the reciprocity is violated, we don’t clear the link from A to B. 3. Then we scan the graph and once we are at B we access A: use-after-free :-D :-) :-| So after loading data, I need to check that every link is reciprocal, and in the vanilla case this is going to be O(N^2), for each node we need to scan all the levels, for each level all the neighbors of the node, and check that it also links to this node by scanning its links at that level. Not good. # Human vs LLM To start, I implemented the vanilla approach, to see if the fuzzer could no longer find the bug, and it worked indeed, but loading times for a big vector set with 20 million vectors went from 45 seconds to 90 or something. WTF. So I opened a Gemini 2.5 PRO chat and told the LLM, hey, what we can do here? Is there a super fast way to do so? The best solution that Gemini could find was to say: order the pointers of the neighbors links, so you can use binary search. Oh, well, sure, I know this, I’m not really sure if in arrays of 16/32 pointers this is going to be faster or slower. So I asked, anything else? Nope, no better solution. So I told it: look, what about when we see A linking B at level X we store in a hash table A:B:X (but we sort A and B always so that A>B, and links are the same whatever the direction), and when we see the link again we clean it, this time we just scan the whole thing as we are already doing when resolving IDs to pointers in the links, and if at the end the hash table is not empty, we know there is some link that must be non-reciprocal? Gemini told me it was a nice idea, but there was the snprintf() to create the key and the hashing time and so forth, but yep, it was better than what my original approach (even sorting pointers). I made it notice that snprintf() was not needed. We could just memcpy() pointers in a fixed sized key. It recognized that it was possible to do so, then I realized something… Hey, I told Gemini, what about using a fixed accumulator for A:B:X? No hash table at all. Each time we see a link (A:B:X, so 8+8+4 bytes) we xor it in the current accumulator of 12 bytes. If we store it twice, it cancels out, so at the end if the register is non-zero, we know something is odd! However I anticipated Gemini that this system was potentially subject to collisions, and to evaluate them. Even if this feature is normally turned off in Redis, when users enable such extra checks they also often expect some more protection against an attacker deliberately crafting bad payloads. Gemini was quite impressed about the idea, but still told that pointers are… you know, similar in structure, change of a few bits, so if there were three spurious links L1, L2, L3 it could happen that the xor between L1 and L2 was the same as the L3 bits, and we could have a false negative (zero register). I also noticed that allocators tend to be very predictable and externally guessable. I asked Gemini for ways to improve upon this: it got no great ideas. Then I thought, wait, we can actually hash this with a good enough hash function that is still fast, murmur-128 or alike (we don’t need it to have cryptographic properties for this task), and proposed the following schema to Gemini: 1. Take the link A:B:X, but use a seed obtained via /dev/urandom to prefix all the keys with it, so we actually have S:A:B:X. 2. We just xor the output of murmur-128(S:A:B:X) into the 128 bit register. 3. At the end, we check if the register is 0 (all links reciprocal). I asked Gemini to do an analysis of that, and it was finally happy, saying that this makes it a lot harder both to casually find orphaned links that happen to xor to 0 together, and even that an external attacker could ever use this in a useful way, since “S” is not known, there is to control the pointers too, and all that it is really hard to put together. Also, this feature is a best effort extra protection that you need to enable, it is normally off and to be practical it should not pose a too big performance penalty. Well, all this to say: I just finished the analysis and stopped to write this blog post, I’m not sure if I’m going to use this system (but likely yes), but, the creativity of humans still have an edge, we are capable of really thinking out of the box, envisioning strange and imprecise solutions that can work better than others. This is something that is extremely hard for LLMs. Still, to verify all my ideas, Gemini was very useful, and maybe I started to think at the problem in such terms because I had a “smart duck” to talk with. Comments
- What I learned during the license switchMay 02, 2025
Yesterday, it was a very intense day. In Italy it was 1st of May, the workers holiday, so in the morning I went for a 4h walk in the Etna with friends Then at 6PM I was at home to release my blog post about the AGPL license switch, and I started following the comments, feedbacks, private messages, and I learned a few things in the process. 1. Regardless of the different few clauses, that IMHO make a difference, the AGPL vs SSPL main difference is that AGPL is "understood". In general, yesterday for the first time I realized that in licensing there is not just what you can do and can't do, but the degree a given license is understood, tested, adopted, ... 2. I was very touched by the words of Simon Willison on the matter (https://simonwillison.net/2025/May/1/redis-is-open-source-again/) because it is very peculiar that different persons, living in different parts of the world, but with a similar age and background in software, feel *so similar* about things. I, too, when was writing Vector Sets, was thinking: I would never use it if it wasn't going to be released under the AGPL (or other open source license I understand). This sentiment, multiplied by a non trivial fraction of the community, makes open source eventually win even in the complex software landscape that there is today. 3. People still care a lot about software distributions. Not that I didn't care, but in the past I burned my fingers with it. I was a very initial Linux user, with SlackWare 3.1 or something like that. During the years I wrote my device drivers, contributed a few patches to the kernel, during the years Debian had maybe ~10 packages of stuff that I wrote, from hping, to the Visitors web log analyzer, dump1090, Redis, and a few more. But, eventually, I started to see all the fragmentation, the rigidity of certain processes (binary compatibility of modules for the Linux kernel), the lack of a consistent design, the lack of a binary format for software distribution with all the libs inside, and so forth. I switched to MacOS on the desktop and continued using Linux on the server in a very pragmatic way, often times more happy to "tar xvzf software.tgz; make" than relying on what distributions offered. And, maybe, my obsession with shipping software with zero dependencies has something to do with it. But people still care a lot, and probably it is important to have Redis as distribution packages in many situations where you want to make things as automatic and reproducible as possible? Well, now there are many folks asking if Redis will re-enter the distributions. My take on that is simple: Redis and ValKey diverged already in a significant way, and will diverge a lot more in the future. I believe distributions should have both, so that users can have a choice, and sometimes this choice is forced by the features difference. Trivially: if you need to do vector similarity searches, you need to use Redis; if instead your company has a no-AGPL policy, you need to use ValKey, and so forth. 4. People are kind to me. In the comments around there were a few harsh takes, and this is normal and even healthy (after all it is part of the reason many companies believe more and more they can't use SSPL or other licenses but an OSI approved one). Yet, when addressing me personally, I see a lot of good words. I just want to say: thank you for all that. 5. We kinda live in a bubble. In one of the forums out there at some point somebody said: "But did you ever switched from Redis to one of the forks?", and there was a chain of comments: "never", "who cares if I can use it" and so forth. And this is true for ValKey too, that if people write apt-get install redis and ValKey is installed instead, and they use SET, GET, DEL, a few more, they don't care. What I mean is that software is no longer the one in 1998 (to use a very crucial and symbolic date for open source, the Internet, and myself) where we were all open source software license experts. Most people, especially the newer generations, have a different and more practical take. So all this is very important (vital, to me), but there is to understand that not every sensibility is alike. In the end, what is the most important aspect of all, is trying to ship good software. Comments
- Redis is open source againMay 01, 2025
Five months ago, I rejoined Redis and quickly started to talk with my colleagues about a possible switch to the AGPL license, only to discover that there was already an ongoing discussion, a very old one, too. Many people, within the company, had the feeling that the AGPL was a better pick than SSPL, and while eventually Redis switched to the SSPL license, the internal discussion continued. I tried to give more strength to the ongoing pro-AGPL license side. My feeling was that the SSPL, in practical terms, failed to be accepted by the community. The OSI wouldn’t accept it, nor would the software community regard the SSPL as an open license. In little time, I saw the hypothesis getting more and more traction, at all levels within the company hierarchy. I’ll be honest: I truly wanted the code I wrote for the new Vector Sets data type to be released under an open source license. Writing open source software is too rooted in me: I rarely wrote anything else in my career. I’m too old to start now. This may be childish, but I wrote Vector Sets with a huge amount of enthusiasm exactly because I knew Redis (and my new work) was going to be open source again. I understand that the core of our work is to improve Redis, to continue building a good system, useful, simple, able to change with the requirements of the software stack. Yet, returning back to an open source license is the basis for such efforts to be coherent with the Redis project, to be accepted by the user base, and to contribute to a human collective effort that is larger than any single company. So, honestly, while I can’t take credit for the license switch, I hope I contributed a little bit to it, because today I’m happy. I’m happy that Redis is open source software again, under the terms of the AGPLv3 license. Now, time to go back to the terminal, to show Redis users some respect by writing the best code I’m able to write, and make Vector Sets more useful and practical: I have a few more ideas for improvements, and I hope that more will be stimulated by your feedback (it is already happening). Good hacking! P.S. Redis 8, the first version of Redis with the new license, is also GA today, with a many new features and speed improvements of the core: https://redis.io/blog/redis-8-ga/ You can also find the Redis CEO blog post here: https://redis.io/blog/agplv3/ Comments
- Reproducing Hacker News writing style fingerprintingApr 16, 2025
About three years ago I saw a quite curious and interesting post on Hacker News. A student, Christopher Tarry, was able to use cosine similarity against a vector of top words frequencies in comments, in order to detect similar HN accounts — and, sometimes, even accounts actually controlled by the same user, that is, fake accounts used to uncover the identity of the writer. This is the original post: https://news.ycombinator.com/item?id=33755016 I was not aware, back then, of Burrows-Delta method for style detection: it seemed kinda magical that you just needed to normalize a frequency vector of top words to reach such quite remarkable results. I read a few wikipedia pages and took mental note of it. Then, as I was working with Vectors for Redis I remembered about this post, searched the web only to discover that the original page was gone and that the author, in the original post and website, didn’t really explained very well how the data was processed, the top words extracted (and, especially, how many were used) and so forth. I thought I could reproduce the work with Vector Sets, once I was done with the main work. Now the new data type is in the release candidate, and I found some time to work on the problem. This is a report of what I did, but before to continue, the mandatory demo site: you can play with it at the following link: https://antirez.com/hnstyle?username=pg&threshold=20&action=search NOTE: since the dataset takes 700MB of RAM, in my tiny server, in the next months I may take this down. However, later in this post you will find the link and the Github repository with the code to reproduce everything from scratch. NOTE2: I hope the web site will survive, it's a very crude Python script. I benchmarked the VSIM command in such a small server and yet it can deliver 80k VSIM per second! The wonders of int8 quantization, together with a few more optimizations. But the Python script is terrible, creates a new Redis connection each time and so forth. Fingers crossed. # Raw data download and processing Well, the first problem I had, in order to do something like that, was to find an archive with Hacker News comments. Luckily there was one with apparently everything posted on HN from the start to 2023, for a huge 10GB of total data. You can find it here: https://huggingface.co/datasets/OpenPipe/hacker-news and, honestly, I’m not really sure how this was obtained, if using scarping or if HN makes this data public in some way. Since I’m not a big fan of binary files, in the specific case of public datasets at least, I used two Python scripts in order to convert the Parquet files into something smaller and simpler to handle. The first script, gen-top-words.py, takes the binary files and generates a txt file with the list of the top N words used in the dataset. It generates 10k words by default, but for the statistical analysis a lot less are needed (or, actually: if you use too many words you no longer capture the style, but the kind of content a user is talking about!). Then, another Python script, accumulates all the comments for each single user and generates a very big JSONL file where there are just two keys: the user name and the frequency table of all the words used by a given user in all the history from HN starts to 2023. Each entry is like that: {"by": "rtghrhtr", "freqtab": {"everyone": 1, "hates": 1, "nvidia": 1, "but": 1, "treats": 1, "ati": 1, "as": 1, "an": 1, "afterthought": 1, "another": 1, "completely": 1, "useless": 1, "tool": 1, "to": 1, "throw": 1, "on": 1, "the": 1, "pile": 1}} At this point, the final script, insert.py, could do all the real work: to apply the Borrows method for each user, create the user style vector, and insert it into Redis. The advantage of pre-processing the files (a slow operation) is that the insertion script could be called more easily with different parameters (especially the number of top words to use) in order to see the different results more promptly, without the need to re-process the Parquet files each time. # How the Burrow method works? In the original post, Christopher wrote that you just need to normalize the frequency of the words usage and apply cosine similarity. Actually the process is a bit more involved. First, let’s ask ourselves, how this method actually works, in its essence? Well, it wants to capture words that each specific user over-uses or under-uses compared to the expected “average” language. To do so, we actually use the following steps (from the Python code). That’s what we do for each of the top words: # Convert to relative frequency rel_freq = frequency / total_words # Standardize using z-score: z = (freq - mean) / stddev mean = word_means.get(word, 0.0) stddev = word_stddevs.get(word, 1.0) # Default to 1.0 to avoid division by zero z_score = (rel_freq - mean) / stddev # Set the z-score directly in the vector at the word's index vector[word_to_index[word]] = z_score So we start by “centering” the frequency the user used a given word, by subtracting the *global* usage frequency for that word. This way, we have a number that describes how much the user under (negative) or over (positive) used such word. But, if you think at it, words that have a much higher variance among usage of different writers are less important, when they change. We want to amplify the signal of words that are under of over used by this user in a much greater way compared to the normal variance of the word. This is why we divide the centered frequency by the global standard deviation of the word. Now we have what is called the “z score”, an adjusted measure of how much a given word is an outlier in one or the other direction. Now, we are ready to insert the word into a Redis vector set, with just: VADD key FP32 [blob with 350 floats] username (I’ll not cover the details of vector sets here since you can find the doc here -> https://github.com/redis/redis/blob/unstable/modules/vector-sets/README.md) Note that Redis performs L2 normalization of the inserted vectors, but remembers the L2 value in order to return back the values when VEMB is used to retrieve the associated vector, so the z_score was set as it is. Finally, with VSIM, we can get similar users: 127.0.0.1:6379> vsim hn_fingerprint ele pg 1) "pg" 2) "karaterobot" 3) "Natsu" 4) "mattmaroon" 5) "chc" 6) "montrose" 7) "jfengel" 8) "emodendroket" 9) "vintermann" 10) "c3534l" All the code (but the webapp itself) can be found here: https://github.com/antirez/hnstyle The README file explains how to reproduce every part. # Why 350 words? One of the things missing in the original post that stimulated this blog post, is how many top words one should use. If you use too many words, you’ll see many comments of mine about Redis, since Redis is one of the top 10k words used. Guess what? I did exactly this error, initially, and VSIM continued to report users that talked about similar topics than myself, not with similar *style*. But fortunately the Internet Archive cached the Christopher results for the “pg” account, here: https://web.archive.org/web/20221126235433/https://stylometry.net/user?username=pg So now I could tune my top-k words to get similar results. Also, reading the original papers, I discovered that, with my surprise, for the analysis to work well you need even as little as 150 words. And in general the range from 150 to 500 is considered to be optimal. Warning: don’t believe that when you search for a user you’ll find mostly fake accounts. For many fake accounts there is too little data, as often people create throw away accounts, write a few comments, and that’s it. So most of the accounts associated with a given user style will be just other people that have a similar writing style. This method I believe is quite powerful in distinguishing who is a native speaker and who is not. This is especially clear from the vectors visualization below. # Validate and visualize… Another thing that I reproduced (also an idea from OP) was to try inserting the same users in two variants, like antirez_A and antirez_B, using two different set of comments. Then check if asking for similar users to antirez_A would report B. Indeed, for *most* of the users I tested this against, it worked very well, and often times it was the top result. So we know that actually our method works. But since from the vectors it is so easy to “see” a style, what about our naked eyes? Recently I switched to Ghostty as my terminal, and it supports the Kitty graphics protocol, so you can display bitmaps directly in the terminal window. It is quite some time I want to play with it. Finally I had a good reason to test this feature. img://antirez.com/misc/hnstyle_1.jpg What’s happening above is that we call the VEMB command, that returns just a list of floats (the vector). Then the vshow utility, also part of the repository, will care to find the smallest square that can contain the vector and show positive values in red, negative in green. As you can see, as a non native speaker I over-use very simple words and under-use more sophisticated words. Other authors stress certain specific words, others are much more “plain”, showing less artifacts. At some point I was curious about what was really happening there: what words I would use too much and too little? So in the demo website you can also press the button to analyze a given user, and see the top 10 words over-used and under-used. Well, a few of mine are definitely due to my issues with English grammar :D Ok, enough with this investigation! Vector sets are now in Redis 8 RC1 and I have more work to do, but this was fun, and I believe it shows that vectors were definitely cool even before AI. Thanks for reading such a long post. EDIT: I forgot to say that the insert.py script also inserts the JSON metadata with the total words written by the user. So you can use FILTER in order to only show matches with a given number of words. This can be useful to detect duplicated accounts since often they are used only seldom, when the identity must be covered: 127.0.0.1:6379> vsim hn_fingerprint ele pg FILTER ".wordcount 1) "montrose" 2) "kar5pt" 3) "ryusage" 4) "corwinstephen" 5) "ElfinTrousers" 6) "beaned" 7) "MichaelDickens" 8) "bananaface" 9) "area51org" 10) "william42" EDIT2: In case the matches look suspicious to you (meaningless), like tptacek noted in a comment in the HN submission of this blog post, here is a "visual" match that shows how, for instance, montrose and pg are really similar in the words usage patterns: img://antirez.com/misc/hnstyle_2.jpg Comments
- Vector Sets are part of RedisApr 03, 2025
Yesterday we finally merged vector sets into Redis, here you can find the README that explains in detail what you get: https://github.com/redis/redis/blob/unstable/modules/vector-sets/README.md The goal of the new data structure is, in short, to create a new “Set alike” data type, similar to Sorted Sets, where instead of having a scalar as a score, you have a vector, and you can add and remove elements the Redis way, without caring about anything except the properties of the abstract data structure Redis implements, ask for elements similar to a given query vector (or a vector associated to some element already in the set), and so forth. But more about that later, a bit of background, first: From the path of the README itself, you can see the implementation is into “modules”, but actually, Vector Sets are not a module, it’s a part of the Redis core, the thing is that I started developing them as a module, and later I suggested that the implementation should still use the modules API, in order to promote modularity of the internals of Redis, in order to have both the advantages: every Redis instance starting from Redis 8 will have Vector Sets as a native data type, and there are clear boundaries between the core and the implementation ## The first new main data type of Redis after… some time I think that the latest big data structure of Redis were Streams, also developed by me. I resigned, returned, forks happened in the meantime, and it still it looks like the burden to introduce a new data type in Redis is mine :D I must say: I’m ok with that, because as much as I like programming, I also like design, a lot, and I had a feeling, that vectors, and vector similarity, are conceptually very simple, so they deserved a very simple API. And that was what I tried to do. Vector Sets are still a beta feature but I can tell you something, I can guarantee you can learn the API in 3 minutes. I decided that a fundamental requirement for implementing vector similarity was to also reimplement from scratch HNSWs (you can see my implementation in hnsw.c), because that was going to be my core data structure, and I didn’t want to grab some random code from GitHub and be happy with it. However, as I started reading the papers, I started to understand that a few pieces were missing. So, as I did in the past with HyperLogLog, where I had to fill a few gaps (here: https://antirez.com/news/75), there was already some new algorithmic challenges. Especially I wanted two things: 1. To have true deletions of nodes. In Vector Sets you can add new elements with VADD, and you can remove elements with VREM. And I wanted the memory to be reclaimed ASAP. 2. I wanted to be sure that as you delete elements, the connectivity properties of the HNSW graph were retained. So this lead me to a few differences compared to other implementations of HNSW. I don’t use tombstone deletions, but effectively unlink the node in the moment it gets deleted, relinking it back with other potential good neighbors. To do so, in turn, my implementation is designed to enforce that links must be reciprocal, it’s no longer a best effort property: this in turn changes quite a bit what you need to do during insertions. Another modification I did in the HNSW was to support the ability to scan the graph with a predicate function, so that you can ask for nodes matching a given expression. This requires to modify the greedy graph scanning algorithm in some way: to collect potential nodes to visit, and to collect the result set, but also to have some early stop condition in case there is too much selectivity in the query. We don’t want trigger a full graph scan, of course. Other than the HNSW modifications, I wanted a few more things that are more pragmatic and obvious: 1. Threading of all the vector similarity requests. Yeah, that’s new in Redis land, but as much as I believe single thread and shared nothing was a good design in general, I think that vectors are special. They are slow, a lot slower than other data structures modeled by Redis. As a bonus point, as I was implementing threaded VSIM (the command that performs vector similarity queries) I also discovered that with a few tricks you can split the read half and the write half of writes in two parts, so that neighbors candidate collections happen in the background, and the actual insertion is performed in the foreground. This splitting however is not the default, and you need to force it with the CAS option of VADD. 2. I wanted to support quantization and even make it the default. So Vector Sets ship with both 8 bit quantization and binary quantization. There is also support for random projection for dimensionality reduction. However as much as I like RP and having bin quants, the reality is that the “killer” for me is int8 quants. They are super fast, take 25% of the memory of FP32, and the results are nearly identical to those from full vectors for most vectors generated via embedding AI models. Btw the end result is, I believe, a very fast implementation. For instance in my machine, with a 3 million items vector set of 300 components each, I get 50/60k VSIM (top 10 items) per second on my laptop. But I encourage you to do your benchmarks. Also note that Vector Sets are serialized on disk as a graph, so when they are loaded back in memory, after a Redis restart, you don’t pay back the insertion time: loading every million of elements take a few seconds and not the minutes needed otherwise to add back into the in-memory HNSW. ## Data structures, not indexes What I’ve said so far is all about the low level stuff. But for me the most interesting part of Vector Sets is the data model and the API supporting it. Many databases propose vector similarity as a kind of index, but that’s Redis, and things in Redis are data structures: no exception this time. You add stuff like that: > VADD mykey FP32 …blob of data… item1 And so forth. So you can have many small vector sets if you want, one per key. And an important thing here is that if you split your vectors into N different keys (hashing the item you are inserting or alike to select which key to pick), then you can merge different VSIM calls against different keys into a single reply: > VSIM word_embeddings_int8 ele "banana" WITHSCORES COUNT 4 1) "banana" 2) "0.9997616112232208" 3) "bananas" 4) "0.8758847117424011" 5) "pineapple" 6) "0.8288004100322723" 7) "mango" 8) "0.8179697692394257" If I get a few of those results from different keys and instances, I can sort by the score (where 1 means identical, 0 opposite vector) and that’s it. So, my feeling is that Vector Sets can be composed into different patterns to handle having many vectors (they consume quite a bit of RAM) into different instances and so forth. Also it is interesting that splitting linearly scale writes, since each subset will hit a given key, and multiple insertions are possible in parallel. As usually, the Redis community will likely figure many usage patterns that are now not obvious. ## How filtering works About filtering, if threading was not common in Redis, let’s imagine JSON! But for the first time, I found a good reason to expose JSON directly in the Redis API, user facing: > VGETATTR word_embeddings_int8 banana {"len": 6} So basically with VSETATTR / VGETATTR (and equivalent option to set the JSON attribute directly when adding the item in VADD) you can associate a string to the items you want. Then you can do things like that: > VSIM word_embeddings_int8 ele "banana" FILTER ".len == 3" 1) "yam" 2) "pea" 3) "fig" 4) "rum" 5) "ube" 6) "oat" 7) "nut" 8) "gum" 9) "soy" 10) "pua" The filter expression is not a programming language, is what you could write inside the if() statement of high level programming languages, with &&, ||, all the obvious operators and so forth (but I bet we will add a few more). Well, the details are into the doc, there are also memory usage examples, in depth discussions about specific features, and so forth. I will extend the documentation soon, I hope. For now I really (really!) hope you’ll enjoy Vector Sets. Please, ping me if you find bugs :) Comments
- AI is useless, but it is our best bet for the futureMar 23, 2025
I used AI with success 5 minutes ago. Just five minutes ago, I was writing a piece of software and relied on AI for assistance. Yet, here I am, starting this blog post by telling you that artificial intelligence, so far, has proven somewhat useless. How can I make such a statement if AI was just so helpful a moment ago? Actually, there's no contradiction here if we clarify exactly what we mean. Here’s the thing: at this very moment, artificial intelligence can support me significantly. If I'm struggling with complicated code or need to understand an advanced scientific paper on math, I can turn to AI for clarity. It can help me generate an image for a project, make a translation, clean my YouTube transcript. Clearly, it’s practical and beneficial in these everyday tasks. However, except for rare, groundbreaking examples like AlphaFold — Google's AI that significantly advanced our understanding of protein folding — AI has yet to genuinely push forward human knowledge in a fundamental way. Aside from these few exceptional results, AI hasn’t (obviously) yet matched the capabilities of the very best human minds. If an AI system were at the same level as the brightest humans (and not better than that: it's not needed for a first humanity jump) we could deploy millions of such systems to accelerate research dramatically, transforming progress expected to take centuries into developments happening within decades, or decades into years. Yet, if artificial intelligence remains stuck at its current level of development indefinitely (even if with small incremental improvements, enough to fire many translators, programmers, drivers, actors, ...), perhaps it might have been better not to have it at all. I mentioned this during a conference here in Sicily. The thought hadn't crossed my mind until I was asked on stage. While I was formulating my reply I asked myself: if we knew AI would only yield minor incremental improvements, would it be worth enduring the social upheaval caused by job losses and other stresses? Possibly not. Technologies should serve humanity by enabling greater cultural development, reducing suffering, and allowing us to achieve what otherwise would be impossible. The current level of AI, while helpful, doesn't fully achieve that. That's why investing in AI is like making a bet. I advocate for further investment and continued progress — not necessarily because of what AI can currently do, but because of what it might become in the future. The advancements we see today often exceed our expectations, hinting at even greater unforeseen breakthroughs tomorrow. For us proponents of AI, the argument shouldn't hinge solely on AI’s current abilities but rather on its potential. Five years from now, AI could offer revolutionary advancements in medicine, saving countless lives. In 10 to 20 years, it might significantly contribute to environmental solutions, clarifying complex issues or providing effective methods to mitigate climate change. Even a modest breakthrough might clarify that climate change dynamics, while serious, can be controlled more effectively than we currently believe, or don't need control as they are not as dramatic as we may think, or that decisive action can resolve the problem within a manageable timeframe. The real stakes are in the future, not the present. To focus exclusively on today's AI capabilities limits our perspective and makes it challenging to convince skeptics. But if future developments meet or exceed expectations, the temporary social problems arising today would be a small price to pay for the immense benefits. However, we must be cautious. Existential risks (AI potentially becoming a catastrophic threat) are real, though minimal. We must remain vigilant and prepared. Social challenges, too, require thoughtful attention from governments and societies. Ultimately, I believe we must take the risk, persist, and explore what's beyond the frontier, because if AI fulfills even some of its enormous potential, it could redefine our capabilities, reshape society, and completely transform humanity’s future: for the better. Now, let me go back to my LLM for free and fast code review. Comments
- Big LLMs weights are a piece of historyMar 16, 2025
By multiple accounts, the web is losing pieces: every year a fraction of old web pages disappear, lost forever. We should regard the Internet Archive as one of the most valuable pieces of modern history; instead, many companies and entities make the chances of the Archive to survive, and accumulate what otherwise will be lost, harder and harder. I understand that the Archive headquarters are located in what used to be a church: well, there is no better way to think of it than as a sacred place. Imagine the long hours spent by old programmers hacking with the Z80 assembly on their Spectrums. All the discussions about the first generation of the Internet. The subcultures that appeared during the 90s. All things that are getting lost, piece by piece. And what about the personal blogs? Pieces of life of single individuals that dumped part of their consciousness on the Internet. Scientific papers and processes that are lost forever as publishers fail, their websites shut down. Early digital art, video games, climate data once published on the Internet and now lost, and many sources of news, as well. This is a known issue and I believe that the obvious approach of trying to preserve everything is going to fail, for practical reasons: a lot of efforts for zero economic gains: the current version of the world is not exactly the best place to make efforts that cost a lot of money and don't pay money. This is why I believe that the LLMs' ability to compress information, even if imprecise, hallucinated, lacking, is better than nothing. DeepSeek V3 is already an available, public lossy compressed view of the Internet, as other very large state of-art models are. This will not bring back all the things we are losing, and we should try hard supporting The Internet Archive and other similar institutions and efforts. But, at the same time, we should focus on a much simpler effort: to make sure that the weights of LLMs publicly released do not get lost, and also to make sure that the Archive is part of the pre-training set as well. Comments
- Reasoning models are just LLMsFeb 09, 2025
It’s not new, but it’s accelerating. People that used to say that LLMs were a fundamentally flawed way to reach any useful reasoning and, in general, to develop any useful tool with some degree of generality, are starting to shuffle the deck, in the hope to look less wrong. They say: “the progresses we are seeing are due to the fact that models like OpenAI o1 or DeepSeek R1 are not just LLMs”. This is false, and it is important to show their mystification as soon as possible. First, DeepSeek R1 (don’t want to talk about o1 / o3, since it’s a private thing we don’t have access to, but it’s very likely the same) is a pure decoder only autoregressive model. It’s the same next token prediction that was so strongly criticized. There isn’t, in any place of the model, any explicit symbolic reasoning or representation. Moreover, R1 Zero has similar reasoning capabilities of R1 without requiring *any* supervised fine tuning, just generating chain of thoughts, and improving it with a reward function, using reinforcement learning, was enough to learn a stronger form of reasoning. Interestingly enough, part of these capabilities were easily distilled into smaller models via SFT, which brings me to the next point. The other fundamental observation is that the S1 paper shows that you need very few examples (as little as 1000) in order for the model to start being able to build complex reasoning steps and solve non trivial mathematical problems. S1, and R1 Zero, hint that in some way in the pre-training step the models already learned the representations needed in order to perform reasoning, just with the unsupervised next word prediction training target. So it’s not just that R1 is a vanilla LLM in its fundamental structure, but also the unsupervised pre-training creates enough representations and potential that, powerful enough LLMs, with RL (and/or some minor SFT), learn to reply to complex questions the users pose (I'm referring to instruct models, an old but yet impressive capability) and to use chain of thoughts to reason about things and provide better answers. Reasoning models are just LLMs, and who said LLMs were a dead end was just wrong. Now, to be wrong happens (even if the version of being wrong, in this instance, was particularly aggressive, particularly in denial of evidences). However, trying to change the history and the terminology in order to be in the right side is, for me, unacceptable. Comments
- We are destroying softwareFeb 08, 2025
We are destroying software by no longer taking complexity into account when adding features or optimizing some dimension. We are destroying software with complex build systems. We are destroying software with an absurd chain of dependencies, making everything bloated and fragile. We are destroying software telling new programmers: “Don’t reinvent the wheel!”. But, reinventing the wheel is how you learn how things work, and is the first step to make new, different wheels. We are destroying software by no longer caring about backward APIs compatibility. We are destroying software pushing for rewrites of things that work. We are destroying software by jumping on every new language, paradigm, and framework. We are destroying software by always underestimating how hard it is to work with existing complex libraries VS creating our stuff. We are destroying software by always thinking that the de-facto standard for XYZ is better than what we can do, tailored specifically for our use case. We are destroying software claiming that code comments are useless. We are destroying software mistaking it for a purely engineering discipline. We are destroying software by making systems that no longer scale down: simple things should be simple to accomplish, in any system. We are destroying software trying to produce code as fast as possible, not as well designed as possible. We are destroying software, and what will be left will no longer give us the joy of hacking. Comments
- From where I leftDec 10, 2024
I’m not the kind of person that develops a strong attachment to their own work. When I decided to leave Redis, about 1620 days ago (~ 4.44 years), I never looked at the source code, commit messages, or anything related to Redis again. From time to time, when I needed Redis, I just downloaded it and compiled it. I just typed “make” and I was very happy to see that, after many years, building Redis was still so simple. My detachment was not the result of me hating my past work. While in the long run my creative work was less and less important and the “handling the project” activities became more and more substantial — a shift that many programmers are able to do, but that’s not my bread and butter — well, I still enjoyed doing Redis stuff when I left. However, I don’t share the vision that most people at my age (I’m 47 now) have: that they are still young. I wanted to do new stuff, especially writing. I wanted to stay more with my family and help my relatives. I definitely needed a break. However, during the “writing years” (I’m still writing, by the way), I often returned to coding, as a way to take breaks from intense writing sessions (writing is the only mental activity I found to be a great deal more taxing than coding): I did a few embedded projects; played more with neural networks; built Telegram bots: a bit of everything. Hacking randomly was cool but, in the long run, my feeling was that I was lacking a real purpose, and every day I started to feel a bigger urgency to be part of the tech world again. At the same time, I saw the Redis community fragmenting, something that was a bit concerning to me, even as an outsider. So I started to think that maybe, after all, I could have a role back in the Redis ecosystem. Perhaps I could be able to reshape the company's attitude towards the community. Maybe I could even help to take back the role of the Redis core as the primary focus of new developments. Basically I could be some kind of “evangelist” (I don’t love the name of this role, but… well, you get it), that is, on one side, a bridge between the company and the community, but also somebody that could produce programming demos, invent and describe new patterns, write documentation, videos and blog posts about new and old stuff. And, what about the design of new stuff? I could learn from the work of people in the wild, from their difficulties, distill it, and report back design ideas, in order for Redis to evolve. # Time in NY At some point my daughter, who is now 12, and is a crucial person in my life, enlightening my days with her intelligence, creativity and love, wanted to visit NYC for her birthday. We decided that yes, this was a good idea after all, we had a couple very difficult years recently, so, why not? My daughter is now more of a girl than a child. So, in NYC, I thought: maybe this is the right time, I can do a part time job. I met the new Redis CEO, Rowan Trollope, very recently, in a video call. I had the feeling that I could work with him to tune the future of the company’s relationships with the community and the codebase direction. So I wrote him an email saying: do you think I could be back in some kind of capacity? Rowan showed interest in my proposal, and quickly we found some agreement. # About the license switch People will ask questions about why I *actually* did this, whether there is some back story other than what I just wrote above, if there is some agreement involved, or a big amount of money; something odd or unclear. But sometimes things are very boring: 1. I contacted the company, not the reverse. 2. I’m not getting crazy money to re-enter, it’s not about exploiting some situation — normal salary (but, disclaimer: yes, I have Redis stock options like I had before, no less, no more). 3. I don’t have huge issues with Redis changing its license; Specifically I don’t think the fracture with the community is *really* about this. But since people will ask me about that very important matter, it is better to tell you all the truth immediately. # The licensing dilemma I wrote open source software for almost my whole life. Yet, as I’m an atheist and still I’m happy when I see other people believing in God, if this helps them to survive life’s hardships, I also don’t believe that open source is the only way to write software. When I started to develop Redis in the context of a company where I was one of the two founders and where we kept the software code closed (Redis was opened as it was considered not part of the core product). We didn't want our services to be copied by others, as simple as that. So I’m not an extremist in this regard – I’m an extremist only about software design. Moreover, I don’t believe that openness and licensing are only what the OSI tells us they are. I see licensing as a spectrum of things you can and can’t do. At the same time, I’m truly concerned that big cloud providers have changed the incentives in the system software arena. Redis was not the only project to change license, it was actually the last one… of a big pile. And I have the feeling that in recent years many projects didn’t even start because of a lack of a clear potential business model. So, the Redis license switch was not my decision and perhaps I would have chosen a different license? I’m not sure, it’s too easy to relitigate right now, far from the scene for many years and without business pressures. But in general, I can understand the choice. Moreover, if you read the new Redis license, sure, it’s not BSD, but basically as long as you don’t sell Redis as a service, you can use it in very similar ways and with similar freedoms as before (what I mean is that you can still modify Redis, redistribute it, use Redis commercially, in your for-profit company, for free, and so forth). You can *even* still sell Redis as a service if you want, as long as you release all the orchestration systems under the same license (something that nobody would likely do, but this shows the copyleft approach of the license). The license language is almost the same as the AGPL, with changes regarding the SAAS stuff. So, not OSI approved? Yes, but I have issues calling the SSPL a closed license. You will say (I can hear you): the real problem is that there are companies controlling OSS projects’ direction! So eventually the interests get more aligned with companies and less with the user base. I’m grateful there are many projects out there with zero direct companies involvement (if not external sponsorship), but well, you know what? Involvement of companies, in many large projects, actually slows down this process of skewing the right path. This surely happened in the case of Redis. # The Robin Hood of software Let's jump back into the past, to the first days of Redis. When Redis started to become popular, I wanted to find a way to continue working on it. This was before VMware offered me to sponsor my work. I started to play with the idea of a business model, and guess what? It was in the form of closed source products that would kinda help people running Redis, in some way or the other. (Amazingly, one of the repositories associated with this idea is still online, showing commits of *15* years ago: https://github.com/antirez/redis-tools) I was about to try some kind of open core approach; I also remember I was thinking about delaying the BSD license for new code for six months, in order to create some kind of advantage for paying users. Now I don’t believe I would be a jerk and do strange games with my users, but I would not be what I was able to be thanks to VMware and, later, more extensively thanks to Redis Labs later: a freaking Robin Hood of open source software, where I was well compensated by a company and no, not to do the interests of the company itself, but only to make the best interests of the Redis community. This is a better setup than having your own company, I’m sure about that. VMWare, and later Redis Labs, didn’t pay just for me. If you give a quick look at the repository contributions history, you’ll see the second all time contributor to be Oran Agra (Redis) then there is Pieter Noordhuis (VMWare) and so forth. So basically I think that 12 years of BSD code written just focusing on the user base is a good deal, and it’s something to be happy about. And right now for me the most important part is that the fracture with the community is not about licensing, or at least it’s not mainly about licensing. Actually the new license can solve some part of it: now there is no longer an incentive to just leave the core in maintenance mode and put the new developments into modules. With the new license, cloud providers can’t just cut and paste the Redis code base and sell it without any revenue sharing (was this really asking for too much? This could have prevented all the license switches you saw lately, not just Redis). With the new license, the spotlight can be back on the Redis core, with new, exciting features in the hands of the developers around the world. With tens of people well compensated for their work pushing useful, well documented changes in the GitHub repository. This is also one of the things I would like to help the company with, and I’ll try hard. We need to make the license switch having good effects on the user base and features: that’s my idea. # About AI, LLMs and vector indexing But there is more: Redis is getting interested in developing vector capabilities, and in general to support the kind of programming you can do with AI. Now, every day, I read Hacker News, and I see a huge amount of technical people who dislike AI and the new developments. I also see a lot of people who don't even care to really try the latest models available in depth (hint: Claude AI is in its own league) and still dismiss them as kinda useless. For me, it’s different. I always loved neural networks. I wrote my first NN library in 2003 and was totally shocked by how powerful and cool the whole concept was. And now, at the end of 2024, I’m finally seeing incredible results in the field, things that looked like sci-fi a few years ago are now possible: Claude AI is my reasoning / editor / coding partner lately. I’m able to accomplish a lot more than I was able to do in the past. I often do *more work* because of AI, but I do better work. Recently I wrote a sci-fi short story for an Italian publisher, and thanks to Claude criticizing parts of it I rewrote the ending and produced a much better work (I didn’t let Claude write a single line of the story or the plot: great use of AI is not making machines do what you can do better). Yesterday I needed to evaluate how much faster dot product could be computed with 8 bit quantization of my vectors; I told Claude I needed a benchmark designed in a specific way, and two minutes later I could test it, modify it, and understand whether it was worthwhile or not. Basically, AI didn’t replace me, AI accelerated me or improved me with feedback about my work. And I believe that (regardless of the popularity of RAG, which is not necessarily the main application, nor the most future proof or useful, as models contexts are becoming larger and larger, and soon popular models attention may have linear complexity), sorry for the digression, I was saying that I believe that learned embeddings are here to stay, and vector search is something that belongs to Redis for several reasons: first because vector indexes are data structures, particularly slow data structures, and such data structures can work very well in memory. Also, because I think I found the perfect API to expose them. During my work in designing Redis, I always showed some contradictory tendencies. I was always ready to say “no” to certain things that looked like perfect fits for the project (named Lua scripts, hash fields expires, that are both part of Redis now, btw) but at the same time I added Lua scripting capabilities — when it looked like nuts, an interpreter inside Redis?! —, the Pub/Sub capability, that seemed out of context, then streams, and even synthetic data structures that don’t exist in computer science books, like sorted sets. Because, for me, the fitness of new features into Redis was about two things: use cases and internal design fit. Redis, for me, is lego for programmers, not a “product”. # Vector sets So recently I started to think that sorted sets can inspire a new data type, where the score is actually a vector. And while I was in talks with Rowan, I started to write a design document, then I started to implement a proof of concept of the new data structure, reimplementing HNSWs from scratch (instead of using one of the available libraries, since I wanted to tune every little bit), the Redis way, and well, I’m not sure how this will end, I’m still in the early stages of coding, but perhaps I may end up contributing code again, if this proposal gets accepted. The module I implemented (that would be later merged into the core – for now it’s a module just for the sake of simplicity) implements new commands that manipulate embeddings directly. I’ll show you just that as a hint: VSIM top_1000_movies_imdb ELE "The Matrix" WITHSCORES 1) "The Matrix" 2) "0.9999999403953552" 3) "Ex Machina" 4) "0.8680362105369568" 5) "Akira" 6) "0.8635441958904266" 7) "District 9" 8) "0.8631418347358704" 9) "The Martian" 10) "0.8608670234680176" 11) "The Bourne Ultimatum" 12) "0.8599717319011688" 13) "The Bourne Supremacy" 14) "0.8591427505016327" 15) "Blade Runner" 16) "0.8585404753684998" 17) "Metropolis" 18) "0.8572960793972015" 19) "Inception" 20) "0.8521313071250916" So you have VSIM, VADD, VCARD, all the obvious stuff. It’s exactly the idea of sorted sets, but with multi-dimensional scores (embeddings!) and K-NN matches. What do you think? And, of course, on top of that there are many implementation tricks to make stuff more efficient. But for now it’s proof of concept code, let me work a bit more on it. I’m implementing threading, dimensionality reduction, quantization, and many more things. Quite fun, to be honest. As you can see, there is no mention of hybrid search, the recent buzzword about vector stores. Again, this is the Redis way: to let the developer have a role and decide on their tradeoffs: they know what they are modeling, after all. You have a vector index per key, and like what programmers were able to do with sorted sets, they will invent interesting splitting strategies, new schemas, Lua scripts, patterns and all that is required in order to model their use cases. Still, while normally the associated item will likely be a small string or a document ID, nothing prevents it from being something more complex, with metadata that can be filtered later (but I’ll resist). I just have the feeling that many use cases don’t really need complex server-side filtering, and can be modeled by pre-partitioning data. What I see with great interest is the addition of a potential STORE option, to store the result into a sorted set instead of returning it to the user, where the score is the similarity, of course. All this also has complex and interesting effects on efficiency, scalability, ability to use scripting, and so forth: I hope I’ll have the opportunity to talk more about it in the next weeks and months. Ok, ok: back to the point of this blog post. But perhaps the above is the *real* point, having new ideas that can be exciting. # So, I’m back 🙂 All this to say that, I’m back. I think it’s the right moment for a big thank you to all the Redis community, for what it has done over the years. See you around, I hope there is something more to add to this journey. P.S. I’m active on BlueSky, if you want to follow the developments of all this. https://bsky.app/profile/antirez.bsky.social Comments
- Playing audio files in a Pi Pico without a DACMar 06, 2024
The Raspberry Pico is suddenly becoming my preferred chip for embedded development. It is well made, durable hardware, with a ton of features that appear designed with smartness and passion (the state machines driving the GPIOs are a killer feature!). Its main weakness, the lack of connectivity, is now resolved by the W variant. The data sheet is excellent and documents every aspect of the chip. Moreover, it is well supported by MicroPython (which I’m using a lot), and the C SDK environment is decent, even if full of useless complexities like today fashion demands: a cmake build system that in turn generates a Makefile, files to define this and that (used libraries, debug outputs, …), and in general a huge overkill for the goal of compiling tiny programs for tiny devices. No, it’s worse than that: all this complexity to generate programs for a FIXED hardware with a fixed set of features (if not for the W / non-W variant). Enough with the rant about how much today software sucks, but it must be remembered. One of the cool things one wants to do with an MCU like that, is generating some sound. The most obvious way to do this is using the built-in PWM feature of the chip. The GPIOs can be configured to just alterante between zero and one at the desired frequency, like that: from machine import Pin, PWM pwm = PWM(Pin(1)) pwm.freq(400) pwm.duty_u16(1000) Assuming you connected a piezo to GND and pin 1 of your Pico, you will hear a square wave sound at 400hz of frequency. Now, there are little sounds as terrible to hear as square waves. Maybe we can do better. I’ll skip all the intermediate steps here, like producing a sin wave, and directly jump to playing a wav file. Once you see how to do that, you can easily generate your own other waves (sin, noise, envelops for such waveforms and so forth). Now you are likely asking yourself: how can I generate the complex wave forms to play a wav file, if the Pico can only switch the pin high or low? A proper non square waveform is composed of different levels, so I would need a DAC! Fortunately we can do all this without a DAC at all, just a single pin of our Pico. ### How complex sound generation works I don’t want to cover too much background here. But all you need to know is that, if you don’t want to generate a trivial square wave, that just alternates between a minimum and maximum level of output, you will need to have intermediate steps, like that: S0: # S1: #### S2: ###### S3: ####### S4: ######## And so forth, where S0 is the first sample, S1, the second sample, … Each sample duration depends on the sampling frequency, that is how many times every second we change (when playing) or sample (when recording) the audio wave. This means that to play a complex sound, we need the ability of our Pico pin to output different voltages. There is a trick to do this with the Pico just using PWM, that is to use a square wave with a very high frequency, but with a different duty cycle for the different voltages we want to generate. So we set a very very high frequency output: pwm.freq(100000) Then, if we want to produce the S0 sample, we set the duty cycle (whose value is between 0 and 65535) to a small value. If we want to produce the S1 sample, we use a higher value, and so forth. In sequence we may want to do something like that: pwm.duty_u16(3000) # S0 pwm.duty_u16(12000) # S1 pwm.duty_u16(18000) # S2 pwm.duty_u16(21000) # S3 pwm.duty_u16(24000) # S4 The duty cycle is how much time the pin is set to 1 versus how much time the pin is set to 0. A duty cycle of 65535 means 100% of time pin high. 0% means all the time low. All this, while preserving the set alternating frequency. So if we zoom like if we have an oscilloscope, we can see what happens during S2 and S3 sample generation: S2: ###################### # # # # ###################### # # # # While S3 will be like: ###################### ###################### # # # ###################### ###################### # # # The pin goes up and down with the same frequency, but in the case of S3 it stays up more. This will produce a higher average voltage. This allows us to approximate our wave. ### Convert and play a WAV file In order to play a wav file, we have to convert it into a raw format that is easy to read using MicroPython. I downloaded a wav file saying “Oh no!” from SoundCloud. So my conversion will look like this: ffmpeg -i ohno.wav -ar 24000 -acodec pcm_u8 -f u8 output.raw Note that we converted the file to 8 bit audio (256 different output levels per sample). Anyway our PWM trick is not going to approximate the different levels so well, and we are resource constrained. You can try with 16 bit as well, but I got decent results like this. Then, upload the output.raw file on the device via mpremote: mpremote cp output.raw : Now write a file called “play.py” or as you wish, with this content: from machine import Pin, PWM pwm = PWM(Pin(1)) pwm.freq(100000) f = open("output.raw","rb") buf = bytearray(4096) while f.readinto(buf) > 0: for sample in buf: pwm.duty_u16(sample x=1 x=1 x=1 x=1 x=1 f.close() What we are doing here is just getting the file, 4096 samples per iteration, then “playing” it by setting different PWM duty cycles one after the other, according to the samples values. The problem is, in our PCM file we have 24000 samples per second (see ffmpeg command line). How can be sure that it matches the MicroPython speed? well, indeed it is not a perfect match, so I added “x=1” statements to delay it a bit to kinda match the pitch that looked correct. Oh, and if you are wondering what the sample The downside of all this is that it will take your program busy while playing. I didn’t test it yet, but MicroPython supports threading, so to have a thread playing the audio could be the way to go. ### Bonus point: sin wave sound generation # Sin wave wave=[] wave_samples = 40 pwm.freq(100000) for i in range(wave_samples): x = i/wave_samples*3.14*2 dc = int((1+math.sin(x))*65000) wave.append(dc) print(wave) for i in range(1000): for dc in wave: pwm.duty_u16(dc) Comments
- First Token Cutoff LLM samplingJan 12, 2024
From a theoretical standpoint, the best reply provided by an LLM is obtained by always picking the token associated with the highest probability. This approach makes the LLM output deterministic, which is not a good property for a number of applications. For this reason, in order to balance LLMs creativity while preserving adherence to the context, different sampling algorithms have been proposed in recent years. Today one of the most used ones, more or less the default, is called top-p: it is a form of nucleus sampling where top-scoring tokens are collected up to a total probability sum of “p”, then random weighted sampling is performed. In this blog post I’ll examine why I believe nucleus sampling may not be the best approach, and will show a simple and understandable alternative in order to avoid the issues of nucleus sampling. The algorithm is yet a work in progress, but by publishing it now I hope to stimulate some discussion / hacking. ## There is some gold in the logits Despite the fact that LLM logits are one of the few completely understandable parts of the LLM inner working, I generally see very little interest in studying their features, investigating more advanced sampling methods, detecting and signaling users uncertainty and likely hallucination. Visualizing the probabilities distribution for successive tokens is a simple and practical exercise in order to gain some insights: !~! In the image we can see the top 32 candidate tokens colored by probability (white = 0, blue = 1), the selected token and the rank of the selected token (highest probability = 0, the previous one = 1, and so forth). In the above example, the Mistral base model knows the birth and death dates of Umberto Eco, so it confidently signals the most likely token with most of the total probability. Other times the model is more perplexed because either there are multiple ways to express the continuation of the text, or because it is not certain about certain facts. Asking the date of a name which birthday was not learned during training produces a different distribution of token probabilities. However in general what we want to avoid is to select suboptimal tokens, putting the LLM generation outside the path of minimum perplexity and hallucination. Nucleus sampling fails at doing this because accumulating tokens up to “p”, depending on the distribution may include tokens that are extremely weaker than the first choice. Consider, for instance, the “writer” token in the image above. Umberto Eco was a writer, indeed. The token associated with writer, while it is not scoring with a very high value, it is still a lot more above the second choice. Yet a p of 0.3 may accumulate the second token with a low value like 0.01, and yield it with some single-digit probability, with the risk of putting the generation in the wrong path. Instead, in the case of the following token, “who”, there are multiple choices with a relatively similar score, that could be used for alternative generations. ## Making use of the avalanche effect A key observation here is that to select too weak tokens for the sake of variability is not a good deal: even if we only exploit generations moments where there are a few good candidates in order to diversify the output, the avalanche effect will help us: the input context will change, thus the output of the LLM will be perturbed, with the effect of making it more likely to produce some alternative version of the text. ## First Token Cutoff (FTC) algorithm DISCLAIMER: The algorithm described here hasn’t undergone any scientific scrutiny. I experimented a couple of days with different sampling algorithms having “bound worst token” properties, and this one looks like the best balance between applicability, results and understandability. The algorithm described here can be informally stated as follows: - When the LLM is strongly biased towards a given candidate, select it. - When there are multiple viable candidates, produce alternatives. - The selection of the worst possible token should be bounded to a given amount. Past work, like Tail Free Sampling, also noted that a selection should be made across a small set of high-quality tokens emitted by the LLM. However in TFS such set is identified by performing the derivative to select a cluster corresponding to the tokens that don’t see a steep curve after which the token quality decreases strongly. In the algorithm proposed here, instead, we want the selection to follow a more bounded and understandable cut-off relative to the highest scoring token T0, attributing to T0 a special meaning compared to all the other tokens: the level of certainty the LLM has during the emission of such token (a proxy of perplexity, basically). Thus the algorithm refuses every token that is worse than a given percentage if compared to T0. The cut off percentage, that can have a value from 0 to 1, is called “co”. An example and viable “co” could be 0.5. This is how the algorithm works: 1. Compute softmax() of logits. 2. Sort tokens by probability. 3. Given T0, the probability of the best token, compute the ratio of all the other tokens as: r = 1 - (T[i] / T0) 4. Select only tokens for which r 5. Perform weighted random pick among the selected tokens. Note that in this way, regardless of the fact that tokens may have a smooth monotonically decreasing value, there is a hard limit to the tokens we can include in the set of possibilities. Instead with other methods that try to identify high-score clusters, this is not the case. ## Practical examples One reason why nucleus top-p sampling does not look to fail catastrophically in the practice, is that often times the first token probability is very high, thus when the perplexity is low, and also all the times casually we don’t collect and then pick low-quality tokens, the generation continues along a sensible path. Things are more problematic when there are successive tokens with probabilities like: 0.25, 0.14, 0.01 With a p=0.4, we could collect the third low quality token and yield it ~3% probability. Now consider First Token Cutoff with a co value of 0.5 (token can be up to 50% worse than first one): The second token r value is: r[t1] = 1-(0.14/0.25) = 0.44 # 0.44 r[t2] = 1-(0.01/0.25) = 0.96 # 0.96 > 0.5, this token is refused ## Example output Outputs of Mistral base model (no instruct) with co=0.7 for the prompt “Sorted sets are”. The outputs are three successful outputs not cherry picked for quality. 1. Sorted sets are a powerful data structure in Redis. They can be used to store sorted lists, to store unique values, to store scores for ranking, and to store a sorted list of sorted sets. 2. Sorted sets are a very powerful data structure. They allow you to store data in a way that makes it easy to find the highest or lowest values in the set, and they also allow you to sort the data. This can be useful for many different tasks, such as ranking users by their score, or finding the most popular items in a database. 3. Sorted sets are a very powerful data structure that can be used to solve many different problems. The most common use case is to store a list of unique elements, each of which has an associated value. For example, you could use a sorted set to store the names of all the people in your family, with their ages as the associated values. ## Why understandability matters Sampling parameters are among the few things that the end user of LLMs, or the API user, must tune. Trial and error is often needed, however to have a single tunable parameter for which there is an immediate real-world description and intuition helps a lot. Moreover "co" is a linear parameter, so it's particularly simple to reason about VS parameters like temperature, or even "p" of top_p that while linear strongly depends on the distribution shape of the logits. ## Future work I’m at the start of my investigations, so I’ll study and evaluate better this algorithm. More than anything else, I would love to see more interest in sampling algorithms and more interest in moving forward from top-p alike approaches. There are perhaps interesting information to collect from the logits distribution. For example it is likely that a linear probe could be able to learn when the hidden layers of an LLM are dealing with some factual information. This, with the token perplexity, could be used in order to show the user of LLMs that some part of the output is likely wrong. In general visualizing tokens probabilities distribution is very informative and in some way allows to touch with bare hands how LLMs work and what are the candidates at each step. ## Reference implementation logits = mx.softmax(logits) np_logits = np.array(logits) # MX -> NumPy np_logits = np_logits.flatten() sorted_indices = np.argsort(np_logits) sorted_indices = sorted_indices[::-1] co = 0.7 j = 1 t0 = np_logits[sorted_indices[0]] while 1 - (np_logits[sorted_indices[j]] / t0) j += 1 accepted_logits = [] for i in range(0,j): accepted_logits.append(float(np_logits[sorted_indices[j]])) accepted_logits = mx.array(accepted_logits) idx = mx.random.categorical(accepted_logits) idx = int(np.array(idx)) # Convert zero-dim array to scalar token_id = sorted_indices[idx] ## Credits These experiments were really simple to perform thanks to the MLX library from Apple, and the cool and smart developers working incessantily at it. MLX is extremely accessible, like it should be: after all LLMs are imprescrutable, but the inference itself is a simple process. Comments
- Translating blog posts with GPT-4, or: on hope and fearJan 09, 2024
My usual process for writing blog posts is more or less in two steps: 1. Think about what I want to say for weeks or months. No, I don’t spend weeks focusing on a blog post, the process is exactly reversed: I write blog posts about things that are so important to me to be in my mind for weeks. 2. Then, once enough ideas collapsed together in a decent form, I write the blog post in 30 minutes, often without caring much about the form, and I hit “publish”. This process usually works writing the titles of the sections as I initially just got the big picture of what I want to say, and then filling the empty paragraphs with text. Why I take step 2 so lightly? Because I got other stuff to do, and if blogging would take more than 30/60 minutes I would rather not blog at all, or blog less, or suffer doing it: all things I want to avoid at all costs. Blogging is too important to let it go. It’s better, for me, to give up on the form. At the same time, this is why many of my blog posts, regardless of the content that may be more or less informative, more or less useful, are generally badly written. I hope that the fact I can write well enough in my mother language in some way it is still visibile in my English posts, but I have the feeling that the extremely limited vocabulary I possess, the grammar errors, the sentence construction that oftentimes I just take from Italian and turn into English, all those limits irremediably damage the reading experience. This is why, for the first time, to write my blog post about LLMs and programming I tried a different approach: I wrote the post in Italian and I translated it to English using GPT-4. The result is a much better blog post than usually, I believe, and the total time to write it was comparable to writing it in English, because writing in Italian in a bit faster, and this compensated the time needed to cut & paste the sections in GPT-4, wait for the output, check that it matched the Italian meaning, and doing a few corrections and rewriting when needed. It shocks me that I can hear my voice when reading the translation. It does not sound written by somebody else. And, interestingly, the tools to spot GPT generated texts tell me that the post was written “100% by human”. At the same time the process may look a bit synthetic: even worse I may lose confidence writing English if I continue along this path, so I’m still not sure how this blog will be written in the future. One thing is sure: the post you are reading is not just written by myself, but as the tradition in this blog demands, not even re-read or corrected if not for a quick second pass. This way you can see what my written English really is, and if you are curious, compare it with the post about LLMs. The difference is not less than huge. Another point of view on the matter could be that my true voice is the one of the translated blog post, so writing in English is the real bluff here. Because the translated post is more representative of my lexical ability in my mother tongue, and not of the reduced one I can feature when writing in English. Maybe it captures more shades of what I really want to say. But then one could go deeper in arguments about what style really is. Is it more about sentence construction, and the way you put down your ideas, or is it a lot more about the vocabulary used, the exact words and adjectives selected to provide a given image and meaning? Probably both, and the two things are quite an inseparable whole. Anyway the simple fact that now, in 2024, I finally have this choice, fulls me of hope and fear. Hope for the possibilities the humanity will have, with machines that can talk. And fear about the potential AI has to make everybody lazy, no longer willing to do things as hard as learning a new language. Comments
- LLMs and Programming in the first days of 2024Jan 02, 2024
I'll start by saying that this article is not meant to be a retrospective on LLMs. It's clear that 2023 was a special year for artificial intelligence: to reiterate that seems rather pointless. Instead, this post aims to be a testimony from an individual programmer. Since the advent of ChatGPT, and later by using LLMs that operate locally, I have made extensive use of this new technology. The goal is to accelerate my ability to write code, but that's not the only purpose. There's also the intent to not waste mental energy on aspects of programming that are not worth the effort. Countless hours spent searching for documentation on peculiar, intellectually uninteresting aspects; the efforts to learn an overly complicated API, often without good reason; writing immediately usable programs that I would discard after a few hours. These are all things I do not want to do, especially now, with Google having become a sea of spam in which to hunt for a few useful things. Meanwhile, I am certainly not a novice in programming. I am capable of writing code without any aid, and indeed, I do so quite often. Over time, I have increasingly used LLMs to write high-level code, especially in Python, and much less so in C. What strikes me about my personal experience with LLMs is that I have learned precisely when to use them and when their use would only slow me down. I have also learned that LLMs are a bit like Wikipedia and all the video courses scattered on YouTube: they help those with the will, ability, and discipline, but they are of marginal benefit to those who have fallen behind. I fear that at least initially, they will only benefit those who already have an advantage. But let's take it step by step. # Omniscient or Parrots? One of the most concerning phenomena of this new wave of novelty and progress in machine learning is the limited ability of AI experts to accept their limited knowledge. Homo sapiens invented neural networks, and then, even more crucially, an algorithm to automatically optimize the parameters of a neural network. Hardware has become capable of training increasingly larger models, and using statistical knowledge about the data to be processed (the priors) and through a lot of trial and error for successive approximations, architectures have been discovered that work better than others. But all in all, neural networks remain quite opaque. In the face of this inability to explain certain emerging capabilities of LLMs, one would have expected more caution from scientists. Instead, many have deeply underestimated LLMs, saying that after all they were nothing more than somewhat advanced Markov chains, capable, at most, of regurgitating extremely limited variations of what they had seen in the training set. Then this notion of the parrot, in the face of evidence, was almost universally retracted. At the same time, much of the enthusiastic masses attributed to LLMs supernatural powers that do not exist in reality. Unfortunately, LLMs can, at most, interpolate in the space represented by the data they have seen during training: and this would already be a lot. In reality, their ability to interpolate is limited (but still astonishing, and also unexpected). Oh, if only the largest LLMs of today could interpolate continuously in the space bounded by all the code they have seen! Even if they would not be able to produce true novelties, they would be able to replace 99% of programmers. The reality is more modest, as it almost always is. An LLM is certainly capable of writing programs that it has not seen in that exact form, showing a certain ability to blend different ideas that appeared in the training set with a certain frequency. It is also clear that this ability has, at the moment, deep limits, and whenever subtle reasoning is required, LLMs fail disastrously. Yet they represent the greatest achievement of AI, from its dawn to today. This seems undeniable. # Stupid but All-Knowing It's true: LLMs are capable, at most, of rudimentary reasoning, often inaccurate, many times peppered with hallucinations about non-existent facts. But they have a vast knowledge. In the field of programming, as well as in other fields for which quality data are available, LLMs are like stupid savants who know a lot of things. It would be terrible to do pair programming with such a partner (for me, pair programming is terrible even in the most general terms): they would have nonsensical ideas and we would have to continuously fight to impose our own. But if this erudite fool is at our disposal and answers all the questions asked of them, things change. Current LLMs will not take us beyond the paths of knowledge, but if we want to tackle a topic we do not know well, they can often lift us from our absolute ignorance to the point where we know enough to move forward on our own. In the field of programming, perhaps their ability would have been of very little interest up to twenty or thirty years ago. Back then you had to know a couple of programming languages, the classic algorithms, and those ten fundamental libraries. The rest you had to add yourself, your own intelligence, expertise, design skills. If you had these ingredients you were an expert programmer, able to do more or less everything. Over time, we have witnessed an explosion of frameworks, programming languages, libraries of all kinds. An explosion of complexity often completely unnecessary and unjustified, but the truth is that things are what they are. And in such a context, an idiot who knows everything is a precious ally. Let me give you an example: my experiments on machine learning were carried forward for at least a year using Keras. Then for various reasons, I switched to PyTorch. I already knew what an embedding or a residual network was, but I didn't feel like studying PyTorch's documentation step by step (as I had done with Keras, which I learned when ChatGPT did not yet exist). With LLMs, it was very easy to write Python code that used Torch. I just needed to have clear ideas about the model I wanted to put together and ask the right questions. # Time for Examples I'm not talking about easy things like: "Hey, what's the method of class X to do Y"? If it were just for that, one might be tempted to agree with those who are skeptical about LLMs. What the more complex models are capable of is much more elaborate. Until a few years ago, it would have been pure magic. I can tell GPT4: look, this is the neural network model I have implemented in PyTorch. These are my batches. I would like to resize the tensors so that the function that emits the batches is compatible with the input of the neural network, and I would like to represent things in this particular way. Can you show me the code needed to do the reshaping? GPT4 writes the code, and all I had to do was test in the Python CLI if the tensors really have the dimensions that are useful to me and if the data layout is correct. Here's another example. Some time ago I had to implement a BLE client for certain ESP32-based devices. After some research, I realized that multi-platform Bluetooth programming bindings are more or less all unusable. The solution was simple, write the code in Objective C using macOS's native API. So, I found myself having to deal with two problems at the same time: learning the cumbersome BLE API of Objective C, full of patterns that I consider nonsensical (I'm a minimalist, that kind of API is at the opposite end of the spectrum of what I consider "good design") and at the same time remembering how to program in Objective C. The last time I had written a program in Objective C was ten years ago: I didn't remember the details of the event loop, memory management, and much more. The final result is this code here, not exactly beautiful, but it does what it has to do. I wrote it in an extremely short time. It would have been impossible otherwise. https://github.com/antirez/freakwan/blob/main/osx-bte-cli/SerialBTE.m The code was written mostly by doing cut & paste on ChatGPT of the things I wanted to do and didn't quite know how to do, so they didn't work properly. Having the LLM explain to me what the problem was and how to solve it. It's true that the LLM didn't write much of that code, but it's also true that it significantly accelerated the writing. Would I have been able to do it without ChatGPT? Certainly yes, but the most interesting thing is not the fact that it would have taken me longer: the truth is that I wouldn't even have tried, because it wouldn't have been worth it. This fact is crucial. The ratio between the effort and the benefit of writing such a program, secondary to my project, would have been inconvenient. Moreover, this had a much more useful secondary collateral effect than the program itself: for that project I modified linenoise (one of my libraries for line editing) so that it works in multiplexing. Another example, this time less about code writing and more about data interpretation. I wanted to set up a Python script using a convolutional neural network I found online, but it was quite lacking in documentation. The network had the advantage of being in ONNX format, so I could easily extract a list of inputs and outputs, and their assigned names. I only knew one thing about this convnet: it detected certain features within an image. I didn't know the input image format and size, and especially, the network's output was far more complicated than I imagined (I thought it was a binary classifier: is the observed image okay or does it have problems? Two outputs, but there were hundreds). I began by copy-pasting the ONNX network metadata output into ChatGPT. I explain to the assistant what little I know about the network. ChatGPT hypothesizes how the inputs are organized, and that the outputs are probably normalized boxes indicating parts of the images corresponding to potential defects, and other outputs indicating the likelihood of these defects. After a few minutes of back-and-forth, I had a Python script capable of network inference, plus the necessary code to transform the starting image into the tensor suitable for input, and so on. What struck me about that session was ChatGPT finally “understood” how the network functioned once it observed the raw output values (the logits, basically) on a test image: a series of floating-point numbers provided the context to identify the exact output details, the normalization, if the boxes where centred or if the left-top corner was specified, and so forth. # Disposable Programs I could document dozens of such cases I've narrated above. It would be pointless, as it's the same story repeating itself in more or less the same way. I have a problem, I need to quickly know something that *I can verify* if the LLM is feeding me nonsense. Well, in such cases, I use the LLM to speed up my need for knowledge. However, there are different cases where I let the LLM write all the code. For example, whenever I need to write a more or less disposable program. Like this one: https://github.com/antirez/simple-language-model/blob/main/plot.py I needed to visualize the loss curve during the learning of a small neural network. I showed GPT4 the format of the CSV file produced by the PyTorch program during learning, and then I requested that if I specified multiple CSV files on the command line, I didn’t want the training and validation loss curves of the same experiment anymore, but a comparison of the validation loss curves of different experiments. The above is the result, as generated by GPT4. Thirty seconds in total. Similarly, I needed a program that read the AirBnB CSV report and grouped my apartments by month and year. Then, considering the cleaning costs, and the number of nights per booking, it would do statistics on the average rental price for different months of the year. This program is extremely useful for me. At the same time, writing it is deadly boring: there's nothing interesting. So I took a nice piece of the CSV file and did copy-paste on GPT4. I wrote to the LLM what the problem was to be solved: the program worked on the first try. I show it to you in full below. ```python import pandas as pd pd.set_option('display.max_rows', None) df = pd.read_csv('listings.csv') reservations = df[df['Type'] == 'Reservation'] reservations['Start Date'] = pd.to_datetime(reservations['Start Date']) reservations['Year'] = reservations['Start Date'].dt.year reservations['Month'] = reservations['Start Date'].dt.month reservations['Nightly Rate'] = (reservations['Amount'] - reservations['Cleaning Fee']) / reservations['Nights'] all_listings = reservations['Listing'].unique() all_years = reservations['Year'].unique() all_months = range(1, 13) index = pd.MultiIndex.from_product([all_listings, all_years, all_months], names=['Listing', 'Year', 'Month']) all_data = pd.DataFrame(index=index).reset_index() merged_data = pd.merge(all_data, reservations, on=['Listing', 'Year', 'Month'], how='left') average_nightly_rates = merged_data.groupby(['Listing', 'Year', 'Month'])['Nightly Rate'].mean().fillna(0) print(average_nightly_rates) ``` To understand exactly how to group the data, which were otherwise quite scattered and disordered, requires some simple reasoning: it’s not a problem that an LLM could solve simply by regurgitating something that approximates the language it has seen. GPT4 has certainly observed similar programs during its training, but almost certainly has not seen a program that had to group things in the exact way I asked for and in a CSV file with that specific format. This shows (in my opinion) that LLMs can interpolate to some extent within the space outlined by the different programs in the training set. Writing this type of program would be, in my opinion, an imprudent use of my time. The fact that an LLM can do it for me certainly speeds up my ability to produce code in a rather direct way: I can focus on the things that matter. # When things don't go as they should: System Programming Despite my success with LLMs and programming, when I write a program in C, I almost always end up using LLMs only as a more convenient form of documentation. I am an expert in system programming, and in that context, LLMs would be useful to me only if they were capable of complex reasoning: in this scenario, they almost always fail. Let's start with the prompt of this experiment: Outline the requirements of a nice, small but working C implementation of a bloom filter. Focus on the important aspects of the hashing function. Then write it, in good quality C. Also consider that your example implementation should be sized to store 100,000 elements, with a false positive probability of at most 5%. The elements added are null-terminated strings. Here’s the output: ```c #include #include #include #include #define N 100000 // Number of elements #define P 0.05 // False positive probability #define M (int)(-(N * log(P)) / (log(2) * log(2))) // Size of bit array #define K (int)(M / N * log(2)) // Number of hash functions unsigned char bitArray[M / 8 + 1]; void setBit(int n) { bitArray[n / 8] |= 1 } int getBit(int n) { return bitArray[n / 8] & (1 } // Basic hash functions (for demonstration purposes) int hash1(char *str) { int hash = 5381; int c; while ((c = *str++)) hash = ((hash return abs(hash % M); } int hash2(char *str) { unsigned long hash = 0; int c; while ((c = *str++)) hash = c + (hash return abs(hash % M); } void add(char *str) { setBit(hash1(str)); setBit(hash2(str)); // Add more hash functions if needed } int is_in(char *str) { return getBit(hash1(str)) && getBit(hash2(str)); // Check more hash functions if used } int main() { char *testStr = "testString"; add(testStr); if (is_in(testStr)) { printf("%s is probably in the set.\n", testStr); } else { printf("%s is definitely not in the set.\n", testStr); } return 0; } ``` The implementation produced by GPT4 is not exactly brilliant. Bloom filters are quite widespread. It’s not such an exotic data structure. However, it’s clear that writing a decent bloom filter requires a greater capacity for abstraction: for example, there’s the problem of finding an effective way to hash the same string N times, and being certain that the hashes are sufficiently decorrelated. Yet if you explicitly ask GPT4 to modify the hashing function so that it produces N decorrelated outputs, its proposed solution is significantly more acceptable: ```c unsigned int hash(const char* str, unsigned int hash_id) { unsigned int hash = 5381; int c; while ((c = *str++)) { hash = ((hash } return hash % BF_SIZE; } ``` If it had come up with this idea on its own, it would have written the bloom filter differently, setting K bits at a time using a single hashing function. GPT4 was able to perform, in isolation, the task of writing an appropriate and more generic hashing function. But in the larger project of writing a bloom filter, it failed to show sufficient reasoning ability, and instead spat out two different but similar hashing functions: this is what one would expect from a poorly written article example on bloom filters. The current weak reasoning abilities of Large Language Models, combined with a possible scarcity of sources on the subject, and even worse, potentially many low-quality sources, collectively result in this meager outcome. Yet, this is not an isolated case. The few times I attempt to use LLMs in the realm of algorithmic or system programming, the results are extremely poor, even when the expected reasoning abilities are not greater than what LLMs seem to possess in contexts like Python programming. At the same time, GPT-4 is capable of decompiling the function it produced (in a separate session), and also precisely understand its purpose: https://chat.openai.com/share/f54b64d7-d19a-4be4-8f10-08553aa812f1 Therefore, stating that there are no uses in system programming contexts is certainly inaccurate. One interesting and pretty much expected thing is that, in these cases, the difference between smaller and larger models is indeed significant. Let’s see how two local models fare. For instance, Mixtral, the MoE of Mistral, responded to the same question as follows: ```c unsigned int hash(const char* str, unsigned int hash_id) { unsigned int hash = 5381; int c; while ((c = *str++)) { hash = ((hash } return hash + hash_id; } ``` Clearly, adding `hash_id` only at the end is an extremely poor solution. Mixtral is an excellent model for many uses, but as for the already weak reasoning capabilities of LLMs, the rule so far is certainly: larger is better. This is a chat with the local LLM deepseek-coder, quantized to 4 bits as I don’t have enough RAM to use the model at higher precision. Yet, being 34 billion parameters large, its reasoning capabilities on this matter seem quite advanced. Here’s the log of the chat (conducted with llama.cpp): > I have a hash function that should output N distinct hashes for the same data. However, I'm seeing a very poor distribution of the produced hashes as I change the value of hash_id: unsigned int hash(const char* str, unsigned int hash_id) { unsigned int hash = 5381; int c; while ((c = *str++)) { hash = ((hash } return hash + hash_id; } Maybe the salting strategy I'm using is poor? How could I change it? [Deep-seek coder reply] A possible issue with your implementation is that you're simply adding `hash_id` to the end of the hash. This can potentially lead to a poor distribution, especially if `hash_id` is too large or is not evenly distributed. Consider using bitwise operations for mixing the `hash_id` in. Here is an example with XOR: ```c unsigned int hash(const char* str, unsigned int hash_id) { unsigned int hash = 5381; int c; while ((c = *str++)) { hash = ((hash } return (hash ^ hash_id); // Mixing the hash_id one more time at the end } ``` This will spread `hash_id` throughout your hashes better than simply adding it, which can lead to less uniform distribution. [Snipped… the actual reply contained more informative stuff] Not bad for a model running on my MacBook M1 Max. It also mixed the sums with XORs. In this case, the model was certainly helped by the fact that I provided clues about the problem to solve, but it was the model that identified what the real source of the trouble was, and the proposed alternative is valid. Something like the above is not within the reach of any book, documentation, or Google search. And as primitive, a result of interpolation, or however you want to perceive it, the model has performed some form of reasoning, if by reasoning, in this specific case, we accept the identification of the origin of a problem and its potential solution. However one wants to think about LLMs, stating that they are not helpful for programmers is extremely rash. At the same time, however, my experience over the past few months suggests that for system programming, LLMs almost never provide acceptable solutions if you are already an experienced programmer. Let me show you another real world example. My current project, ggufflib, involves writing a library that reads and writes GGUF format files, which is the format in which llama.cpp loads quantized models. Initially, to understand how the quantization encodings worked (for speed reasons the bits of each quant are stored in fancy ways), I tried using ChatGPT, but then I resolved to reverse engineer llama.cpp's code: it was much faster. An LLM that can decently assist a system programmer, if it sees the data encoding “struct” declaration and the decoding function, should be able to reconstruct the data format documentation. The functions of llama.cpp were small enough to fit entirely in the context of GPT4, yet the output was completely useless. In these cases, things are done as in the past: paper and pen, reading the code, and seeing where the bits that the decoder extracts are registered. Let me explain better the above use case so that you can try it yourself, if you wish. We have this structure from llama.cpp implementation. // 6-bit quantization // weight is represented as x = a * q // 16 blocks of 16 elements each // Effectively 6.5625 bits per weight typedef struct { uint8_t ql[QK_K/2]; // quants, lower 4 bits uint8_t qh[QK_K/4]; // quants, upper 2 bits int8_t scales[QK_K/16]; // scales, quantized with 8 bits ggml_fp16_t d; // super-block scale } block_q6_K; Then there is this function that is used to perform the dequantization: void dequantize_row_q6_K(const block_q6_K * restrict x, float * restrict y, int k) { assert(k % QK_K == 0); const int nb = k / QK_K; for (int i = 0; i const float d = GGML_FP16_TO_FP32(x[i].d); const uint8_t * restrict ql = x[i].ql; const uint8_t * restrict qh = x[i].qh; const int8_t * restrict sc = x[i].scales; for (int n = 0; n for (int l = 0; l int is = l/16; const int8_t q1 = (int8_t)((ql[l + 0] & 0xF) | (((qh[l] >> 0) & 3) const int8_t q2 = (int8_t)((ql[l + 32] & 0xF) | (((qh[l] >> 2) & 3) const int8_t q3 = (int8_t)((ql[l + 0] >> 4) | (((qh[l] >> 4) & 3) const int8_t q4 = (int8_t)((ql[l + 32] >> 4) | (((qh[l] >> 6) & 3) y[l + 0] = d * sc[is + 0] * q1; y[l + 32] = d * sc[is + 2] * q2; y[l + 64] = d * sc[is + 4] * q3; y[l + 96] = d * sc[is + 6] * q4; } y += 128; ql += 64; qh += 32; sc += 8; } } } If I ask GPT4 to write an outline of the format used, it struggles to provide a clear explanation of how the blocks are stored on the lower / upper 4 bits of “ql” depending on the weight position. For this blog post, I also tried asking it to write a simpler function that shows how data is stored (maybe it can’t explain it with words, but can with code). The produced function is broken in many ways, the indexes are wrong, the 6-bit -> 8-bit sign extension is wrong (it just casts to uint8_t), and so forth. Btw, this is the code that I ended writing myself: } else if (tensor->type == GGUF_TYPE_Q6_K) { uint8_t *block = (uint8_t*)tensor->weights_data; uint64_t i = 0; // i-th weight to dequantize. while(i num_weights) { float super_scale = from_half(*((uint16_t*)(block+128+64+16))); uint8_t *L = block; uint8_t *H = block+128; int8_t *scales = (int8_t*)block+128+64; for (int cluster = 0; cluster for (uint64_t j = 0; j f[i] = (super_scale * scales[j/16]) * ((int8_t) ((((L[j%64] >> (j/64*4)) & 0xF) | (((H[j%32] >> (j/32*2)) & 3) i++; if (i == tensor->num_weights) return f; } L += 64; H += 32; scales += 8; } block += 128+64+16+2; // Go to the next block. } } From the function above, I removed what was the actual contribution of this code: the long comments documenting the exact format used by llama.cpp Q6_K encoding. Now, it would be immensely useful if GPT could do this for me, and I bet it’s just a matter of months, because these kind of tasks are within what can be reached without any breakthrough, just with a bit of scaling. # Putting Things in Perspective I regret to say it, but it's true: most of today's programming consists of regurgitating the same things in slightly different forms. High levels of reasoning are not required. LLMs are quite good at doing this, although they remain strongly limited by the maximum size of their context. This should really make programmers think. Is it worth writing programs of this kind? Sure, you get paid, and quite handsomely, but if an LLM can do part of it, maybe it's not the best place to be in five or ten years. And then, do LLMs have some reasoning abilities, or is it all a bluff? Perhaps at times, they seem to reason only because, as semioticians would say, the "signifier" gives the impression of a meaning that actually does not exist. Those who have worked enough with LLMs, while accepting their limits, know for sure that it cannot be so: their ability to blend what they have seen before goes well beyond randomly regurgitating words. As much as their training was mostly carried out during pre-training, in predicting the next token, this goal forces the model to create some form of abstract model. This model is weak, patchy, and imperfect, but it must exist if we observe what we observe. If our mathematical certainties are doubtful and the greatest experts are often on opposing positions, believing what one sees with their own eyes seems a wise approach. Finally, what sense does it make today not to use LLMs for programming? Asking LLMs the right questions is a fundamental skill. The less it is practiced, the less one will be able to improve their work thanks to AI. And then, developing a descriptive ability of problems is also useful when talking to other human beings. LLMs are not the only ones who sometimes don't understand what we want to say. Communicating poorly is a great limitation, and many programmers communicate very poorly despite being very capable in their specific field. And now Google is unusable: using LLMs even just as a compressed form of documentation is a good idea. For my part, I will continue to make extensive use of them. I have never loved learning the details of an obscure communication protocol or the convoluted methods of a library written by someone who wants to show how good they are. It seems like "junk knowledge" to me. LLMs save me from all this more and more every day. Comments
- The origins of the Idle ScanOct 19, 2023
The Idle scan was conceived at the end of 1998, evidenced by emails. I had moved to Milan a few months prior, having been there since September if I recall correctly, brimming with new ideas, unaware that my stay in that city would be brief. I spent the summer on the beaches of Sicily, mainly occupied with reading many books recommended by the folks at Seclab (mostly by David). However, those readings needed a catalyst: the Idle scan was an attack born from theoretical rumination, but the stream of thoughts originated from a rather practical circumstance. I had recently created Hping, a tool whose logo was borrowed from that of Nutella. I mention this to emphasize the seriousness that governed my efforts at that time — after all, I was only twenty-one and already in Northern Italy with a full-time job on my shoulders; some understanding was warranted. Hping was a Swiss Army knife for the TCP/IP protocol. Its initial use was mostly exploratory, for research. With Hping, you could assemble TCP, UDP, and ICMP packets in the most bizarre manner, and encapsulate them in equally eccentric IP packets, fragmented, with fields set to anomalous values. These packets were sent around to observe the network stack response of different operating systems. This is where Idle scan originates: playing with Hping for just a few minutes revealed a well-known yet (to me) surprising fact. The response packets had an ID field that continuously incremented by some measure. At that time, given that the attacks I would later disclose were not yet known, this ID field behavior aroused no concern. Every time an operating system emitted an IP packet, it first incremented a counter (which reset to zero once it reached the maximum value of two to the sixteenth power minus one), then the packet was sent with the ID set to the counter's value. The counter was universal for all outgoing packets. This allowed, for starters, to estimate the outgoing traffic of any networked computer. This information leak struck me; I saw it as a concerning anomaly. I wrote an initial post on BUGTRAQ, highlighting the issue. Among the responses was one from an Internet luminary, someone who had drafted central RFCs for the TCP/IP protocol. He said that, yes, they were aware; it was a well-known fact. In short: although this characteristic could be used for traffic estimation, most saw no risk. And resolving that issue, deemed negligible, would require a significant overhaul of operating systems. Not worth it. But I was losing sleep over it, hardly a secondary issue to me. I was convinced that the information provided by the incrementing ID field could be combined with other elements (which I had not fully grasped yet) to mount a far more serious attack. I discussed it with Lorenzo Cavallaro. Lorenzo had introduced me to raw socket techniques months earlier, which I had used to write Hping. He had become my go-to conversational partner, as well as a dear friend; if I had to ponder on TCP/IP-related matters, I would discuss them with him. When I told him about these new ideas, I had to be vague, not by choice but because I hadn't yet pieced together the final attack. Despite my vagueness, he appeared fairly interested. A couple of days later, I spoke to him again. Finally, I managed to give him a more comprehensive description of the Idle scan (not yet named as such). I had reasoned it out on paper, as there was no way to test it practically. If I remember correctly, Hping was missing some of the necessary functions, although I wouldn't bet on this part: it's been twenty-five years. My memory clears as I write. I believe you couldn't choose the exact flags of the outgoing TCP packet. Anyway, from what I remember, the modifications to Hping took little time, so we were able to test my theory in practice as well. The Idle scan really worked, and proving it in real conditions was very exciting. I don't remember who made this parallel, but essentially, it was like observing the movement of an invisible celestial body indirectly, measuring only its gravitational effects on another, more easily observable body, much like the discovery of Pluto. All that was left was to make the attack public. Unfortunately, the thought of writing a lengthy email in English terrified me. I was aware that the Idle scan was an interesting idea, and precisely because of that, I was afraid of botching its announcement due to poor presentation. After all, the attack was cumbersome; it could even end up being described so poorly as to make it incomprehensible. Lorenzo and I wrote that email late at night, at my place, certainly drunk. Better than writing it alone; Lorenzo's English, even if slightly, was better than mine. It happened that a colleague from Seclab (also one of the founders), whom I consider a true genius in information security, came from a family of restaurateurs from Emilia. One evening he shows up at my place with a bag. Inside the bag is a fine piece of grey mullet roe, which I had never seen, let alone tasted (it would become one of my favorite foods). The cook-hacker prepares pasta for all of us, and together we eat and drink sufficiently. At the end of the evening, the others leave (there was someone else, besides the cook-hacker), but Lorenzo stays a bit longer. That evening, Lorenzo and I, fairly tipsy, recklessly thought the time had come to write the Idle scan announcement email (still preserved in the BUGTRAQ archives, and which today appears genuinely delirious [1]). We sit down at my PC to work. The email opens like this: I have uncovered a new tcp port scan method. Instead all others it allows you to scan using spoofed packets, so scanned hosts can't see your real address. Not just the grammar, even the peremptory tone of the email is utterly inappropriate. To top it off, we propose calling the attack dumb host scan, but fortunately, someone quickly finds a better name, which became the official one: Idle scan. Long story short, despite its flawed origins, the Idle scan has become a classic attack, and it's safe to say, as is often the case, that the rest is history. [1] https://seclists.org/bugtraq/1998/Dec/79 Comments
Alex Edwards
- How I use HTMX with GoJul 14, 2026
When I want to add sprinkles of interactivity to a web application, I'm a big fan of using HTMX. I like that it makes it easy to give interactions a smooth app-like feel, I like that it minimizes the amount of JavaScript that I have to write, and I like that it allows me to keep the consistency and safety of server-side HTML rendering with Go's html/template package. In this post I'm going to run through how I typically use HTMX in conjunction with Go. Although I'm going to talk a bit about how HTMX works, the main focus is going to be on the Go side of things. Specifically: Structuring and rendering HTML templates The patterns I use for sending back partial and full-page HTML responses to HTMX Managing redirects and errors when using HTMX The standard HTMX configuration settings that I use, and why To illustrate these things, we'll run through the build of a small application that ultimately implements a filter on a list of users like this: Note: If you're not already familiar with the basics of using HTMX, I recommend skimming through the HTMX docs before continuing. Also note: A lot of the patterns for working with HTML templates should also be a good fit for other HTML-over-the-wire tools like Unpoly and Hotwire too, if you prefer to use those. Project setup If you'd like to follow along, go ahead and run the following commands to create a skeleton structure for the project: $ go mod init example.com/htmx $ mkdir -p assets/static/css assets/static/img assets/static/js assets/html/partials assets/html/pages cmd/web $ touch assets/efs.go assets/html/base.tmpl assets/html/partials/images.tmpl assets/html/pages/home.tmpl cmd/web/main.go cmd/web/handlers.go cmd/web/html.go That should give you a file tree which looks like this: . ├── assets │ ├── efs.go │ ├── html │ │ ├── base.tmpl │ │ ├── pages │ │ │ └── home.tmpl │ │ └── partials │ │ └── images.tmpl │ └── static │ ├── css │ ├── img │ └── js ├── cmd │ └── web │ ├── handlers.go │ ├── html.go │ └── main.go └── go.mod Installing HTMX There are a few different ways to install HTMX, and you could load it from a CDN or install it using NPM, but I almost always download a copy and serve it as a static file from my web application. It's simple and avoids the downsides of using a CDN. For the purpose of this demo project, we'll also download Bamboo (a classless CSS framework) and an image of a gopher from github.com/egonelbre/gophers. Go ahead and run the following commands to download all three things into the assets/static folder: $ wget -P assets/static/js https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js $ wget -P assets/static/css https://cdn.jsdelivr.net/npm/bamboo.css@1.4.0/dist/bamboo.min.css $ wget -O assets/static/img/gopher.png https://raw.githubusercontent.com/egonelbre/gophers/refs/heads/master/sketch/misc/standing-left.png The contents of assets/static should now look like this: assets/static ├── css │ └── bamboo.min.css ├── img │ └── gopher.png └── js └── htmx.min.js The HTML templates OK, now that the project skeleton and our static assets are in place, let's get to the main thrust of this post and talk about HTML templates. My starting point in almost all projects is an assets/html directory which has a folder structure like this: assets/html ├── base.tmpl ├── pages │ └── home.tmpl └── partials └── images.tmpl Under this structure: The assets/html/base.tmpl file contains the common HTML 'layout' markup for all web pages. The files in the assets/html/pages directory contain the page-specific content for individual web pages. The files in the assets/html/partials directory contain reusable chunks of HTML markup that can be used in different places. If you're following along, go ahead and add the following markup to the base.tmpl file: File: assets/html/base.tmpl {{define "base"}} {{template "page:title" .}} Example website {{template "page:content" .}} {{end}} There are a few things to point out about this: In the section we import the Bamboo CSS file and the HTMX JavaScript file. Note that when importing HTMX we use the defer attribute. This means that HTMX will be fetched by the browser in parallel as it is parsing the web page HTML, but the script won't be executed until the HTML is fully parsed and the DOM is built. There's an excellent blog post which describes how defer works and why it's the right choice here. When writing HTML templates, I like to give all of my templates explicit names by surrounding the markup in {{define}}...{{end}} actions — even if (like in this case) a file only contains one template and it's not strictly necessary. YMMV, but I prefer the consistency and clarity of being able to always refer to templates by defined names from my Go code, rather than using a mixture of defined names and filenames. Within the template, we use actions like {{template "page:title" .}} to inject the appropriate page-specific content in the right place. Talking of which, let's now add the page-specific content for the homepage to the assets/html/pages/home.tmpl file: File: assets/html/pages/home.tmpl {{define "page:title"}}Home{{end}} {{define "page:content"}} Wanna see a cute gopher? {{end}} In this page we have a with two HTMX attributes: hx-get="/gopher" and hx-swap="outerHTML". These mean that when this button is clicked, HTMX will intercept the click, send a GET /gopher request to our application, and then replace the button in the DOM with whatever HTML our application sends back. Note: The colon character in the template name like {{define "page:title"}} is just an arbitrary separator and you could name it something else, like page_title, page-title, pageTitle or even just title if you prefer. But I like using : because it feels like a natural and clear way to 'namespace' template names. Lastly, let's add a template to the assets/html/partials/images.tmpl containing some HTML for displaying our downloaded gopher image, like so: File: assets/html/partials/images.tmpl {{define "partial:image:gopher"}} {{end}} Note that we're using width="{{.}}" in this markup, so that we can pass a dynamic value for the image width to the template. Embedding the assets Since file embedding was introduced in Go 1.16, I normally embed HTML files and static assets into a Go binary rather than reading them from disk at runtime. Let's update the assets/efs.go file to embed the contents of the assets/html and assets/static directories, and make them available in two global variables called HTMLFiles and StaticFiles respectively. Like so: File: assets/efs.go package assets import ( "embed" "io/fs" ) //go:embed "html" "static" var files embed.FS var ( HTMLFiles = sub(files, "html") StaticFiles = sub(files, "static") ) func sub(f embed.FS, dir string) fs.FS { sub, err := fs.Sub(f, dir) if err != nil { panic(err) } return sub } In this code, the //go:embed "html" "static" directive embeds the contents of the assets/html and assets/static directories into the files variable, which is an embed.FS rooted in the assets directory. I've then used a small sub() function to create two sub-filesystems with their roots in the html and static directories, and assigned them to the HTMLFiles and StaticFiles variables respectively. Doing this has two benefits: It provides a clear separation between the static and HTML files when we are using them from our Go code. Code that is intended to only work with our static files won't have unnecessary access to our HTML files, and vice-versa. Code using the HTMLFiles and StaticFiles filesystems doesn't need to include the html/ or static/ path prefix when opening files. Note: If you don't want to call panic() from the sub() function, you could restructure this to return an error instead, and initialize the HTMLFiles and StaticFiles variables from within your main() function. But the risk of a runtime panic here is extremely low — the fs.Sub() function will only return an error if the dir value is not a valid path, and the static strings "html" and "static" always pass this check. In practice, I've never had any problems using this approach. HTML template rendering For rendering the HTML templates in an HTTP response, I've found that a nice pattern is to create a htmlRenderer type which a) parses a set of shared templates at startup; b) has a render() method that clones and extends the shared template set, before executing a specific named template and sending it as an HTTP response. Go ahead and create the htmlRenderer type in the cmd/web/html.go file like so: File: cmd/web/html.go package main import ( "bytes" "html/template" "io/fs" "net/http" "time" ) type htmlRenderer struct { templateFS fs.FS sharedTemplates *template.Template } // The newHTMLRenderer function creates a new htmlRenderer containing a shared // set of parsed templates with support for any custom template functions. func newHTMLRenderer(templateFS fs.FS, sharedTemplateFiles ...string) (*htmlRenderer, error) { funcs := template.FuncMap{ "now": time.Now, // Other custom template functions go here... } sharedTemplates, err := template.New("").Funcs(funcs).ParseFS(templateFS, sharedTemplateFiles...) if err != nil { return nil, err } r := &htmlRenderer{ templateFS: templateFS, sharedTemplates: sharedTemplates, } return r, nil } // The render method clones the shared template set, optionally parses additional // templates, executes the named template with the supplied data, and writes the // response. func (h *htmlRenderer) render(w http.ResponseWriter, status int, data any, templateName string, additionalTemplateFiles ...string) error { ts, err := h.sharedTemplates.Clone() if err != nil { return err } if len(additionalTemplateFiles) > 0 { ts, err = ts.ParseFS(h.templateFS, additionalTemplateFiles...) if err != nil { return err } } buf := new(bytes.Buffer) err = ts.ExecuteTemplate(buf, templateName, data) if err != nil { return err } w.WriteHeader(status) buf.WriteTo(w) return nil } And then in the cmd/web/main.go file, let's create a basic web application like so: File: cmd/web/main.go package main import ( "log/slog" "net/http" "os" "example.com/htmx/assets" ) // The application struct holds the dependencies needed for our handlers, // including a htmlRenderer type. type application struct { logger *slog.Logger html *htmlRenderer } func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) // Initialize a new htmlRenderer, parsing the base template and all partial // templates from assets/html into the shared template set. htmlRenderer, err := newHTMLRenderer(assets.HTMLFiles, "base.tmpl", "partials/*.tmpl") if err != nil { logger.Error(err.Error()) os.Exit(1) } // Include the htmlRenderer in the application struct. app := &application{ logger: logger, html: htmlRenderer, } // Create a file server that serves the files from assets/static. fileserver := http.FileServerFS(assets.StaticFiles) // Register the application routes. mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static", fileserver)) mux.HandleFunc("GET /{$}", app.home) // Start the HTTP server. logger.Info("starting server", "port", 5051) err = http.ListenAndServe(":5051", mux) if err != nil { logger.Error(err.Error()) os.Exit(1) } } The important and relevant thing for this post is the initialization call to newHTMLRenderer(). In this call we pass in the glob paths "base.tmpl" and "partials/*.tmpl", which means that the base template and all templates in the partials directory will be available in the shared template set. And with that in place, we can then write the code for the home handler in cmd/web/handlers.go like so: File: cmd/web/handlers.go package main import ( "net/http" ) func (app *application) home(w http.ResponseWriter, r *http.Request) { err := app.html.render(w, 200, nil, "base", "pages/home.tmpl") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } When we call render() in the code above, we are effectively saying append the templates in pages/home.tmpl to the shared template set, and then render the base template along with a 200 OK status. At this point, you should be able to successfully run the application: $ go run ./... time=2026-06-27T21:05:01.668+02:00 level=INFO msg="starting server" port=5051 And if you visit http://localhost:5051 in your browser, you should see the homepage displayed like so: Rendering partials While you're on this homepage, if you open developer tools and then click the "Wanna see a cute gopher?" button, you'll see that it sends a GET /gopher request that 404s. Let's fix this so that our application includes a GET /gopher route, which returns the contents of the partial:image:gopher template. First add the new route like so: File: cmd/web/main.go package main .. func main() { ... mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static", fileserver)) mux.HandleFunc("GET /{$}", app.home) mux.HandleFunc("GET /gopher", app.gopher) ... } And then in cmd/web/handlers.go create a new gopher() handler, which renders the partial:image:gopher template with a width of 100px. File: cmd/web/handlers.go package main ... func (app *application) gopher(w http.ResponseWriter, r *http.Request) { width := 100 err := app.html.render(w, http.StatusOK, width, "partial:image:gopher") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } Because we've set up our htmlRenderer type so that the shared template set already includes all partials, it's sufficient for us to call render() like this without passing in any additional file paths. If you re-run the application now and click the button, you should see that it gets swapped out for a gopher image like so: So, it's taken a while to get here, but the pattern that we now have in place is neat and has some nice benefits. Our templates (and static assets) are embedded into the Go binary, which makes for easy distribution and deployment. We can use the same htmlRenderer.render() function to send either complete HTML pages or specific partials to the client, which makes it easy to send back partial responses when they are needed by HTMX. We can keep the HTML markup nice and DRY by using the base template and partials. The partials can be inserted in the base template, page-specific content, or even in other partials. A more complex example That was very basic in terms of interactivity, so let's do something a bit more realistic and create a 'user search' page that mimics the active search example from the HTMX website. To make this work, we'll create two new routes in our application: A GET /users route which returns a full HTML page containing a table of all user details. A GET /users/search route which returns an HTML partial containing table rows only for users whose names or emails match a specific search value. Now that we've got all the groundwork in place, it should be pretty quick to do. Let's first add an assets/html/pages/users.tmpl file with the page-specific HTML content: $ touch assets/html/pages/users.tmpl File: assets/html/pages/users.tmpl {{define "page:title"}}Users{{end}} {{define "page:content"}} Name Email {{template "users:rows" .}} {{end}} {{define "users:rows"}} {{range .}} {{ .Name }} {{ .Email }} {{if .IsGopher}} {{template "partial:image:gopher" 24}} {{end}} {{end}} {{end}} There are a couple of interesting things here. The first is the HTMX attributes on the control. We've configured this so that when a user types into the input, after a delay of 500ms (or immediately if they press Enter), HTMX will send a request containing the search term as a query string like GET /users/search?query=foo. When a response is received, HTMX will then swap the response into the inner HTML of the element. For demonstration purposes in this project, we're also using the hx-push-url="true" attribute, which will result in the browser URL bar being updated and a new entry added to the browser history each time HTMX makes a request. I've also structured the file so that the table rows are rendered in their own users:rows template, rather than as part of the page:content template. We'll use this in the GET /users/search to render just the matching user table rows for HTMX to swap in. Note: In theory, we could define the users:rows template inside the partials directory instead, and that wouldn't be an unreasonable thing to do. But if I have a HTML fragment that is only used on one specific page, I think it's clearer and neater to define that fragment inside the page file alongside the other content for the page. YMMV though, and that's OK. Then let's set up the two new routes in main.go: File: cmd/web/main.go package main .. func main() { ... mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static", fileserver)) mux.HandleFunc("GET /{$}", app.home) mux.HandleFunc("GET /gopher", app.gopher) mux.HandleFunc("GET /users", app.listUsers) mux.HandleFunc("GET /users/search", app.searchUsers) ... } And lastly let's go to the handlers.go file and create a hardcoded list of user details, along with the two new handlers listUsers and searchUsers, like so: File: cmd/web/handlers.go package main import ( "net/http" "strings" ) ... // Define a user type. The fields need to be exported so that we can reference // them in our HTML templates. type user struct { Name string Email string IsGopher bool } // Create a hardcoded list of users. var users = []user{ {"Alice Madsen", "alice.madsen@example.com", true}, {"Theo Thatcher", "theo.thatcher@example.com", true}, {"Maxwell Albright", "maxwell.albright@example.com", false}, {"Ruby Thompson", "ruby.thompson@example.com", false}, {"Leona Rowan", "leona.rowan@example.com", false}, {"Alicia Lennox", "alicia.lennox@example.com", true}, {"Ruben Mason", "ruben.mason@example.com", false}, {"Leo Reynolds", "leo.reynolds@example.com", false}, {"Max Lester", "max.lester@example.com", true}, {"Theodore Allister", "theodore.allister@example.com", false}, } func (app *application) listUsers(w http.ResponseWriter, r *http.Request) { // Render a full HTML page containing the content from "pages/users.tmpl" // and all user details. err := app.html.render(w, 200, users, "base", "pages/users.tmpl") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } func (app *application) searchUsers(w http.ResponseWriter, r *http.Request) { // Filter down the list of users to find ones that match the query. query := r.FormValue("query") var matches []user if query == "" { matches = users } else { for _, u := range users { if strings.Contains(u.Name, query) || strings.Contains(u.Email, query) { matches = append(matches, u) } } } // Render just the "users:rows" template from the "pages/users.tmpl" file // with the matching user details. err := app.html.render(w, 200, matches, "users:rows", "pages/users.tmpl") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } When it comes to template rendering, in both of these new handlers we are adding the templates from the pages/users.tmpl file to the shared template set, but in listUsers we execute the base template and in searchUsers we execute just the users:rows template. So with just a little bit of thought to how we structured the markup and defined the templates in the pages/users.tmpl file, it's straightforward for us to send back either a complete HTML document or the appropriate partial HTML fragment for HTMX to do its thing. If you want, try this out by visiting http://localhost:5051/users and you should see the list being filtered as you type. Note: I've been deliberately keeping this web application simple so that the focus is on templates and templating. In a real application, you might want to merge listUsers and searchUsers into a single handler, create some centralized helpers for error handling and logging, use middleware to add Content Security Policy headers and recover panics, set appropriate server timeouts, etc. Checking if a request is coming from HTMX This all works well, but what if someone visits a link like http://localhost:5051/users/search?query=leo directly? Or shares a link to it? Anyone visiting this directly would only see the partial HTML response in their browser, similar to this: This obviously isn't ideal. A much better approach would be to change the response that our searchUsers handler sends, depending on whether the request is coming from HTMX or not. Specifically: If the request is coming from HTMX, we should return an HTML partial that it can swap into the table, just like we already are. If the request is not coming from HTMX, we should return a full HTML page that contains the matching user details. As you may already know if you've used HTMX before, requests that come from HTMX always include an HX-Request: true header. So all we need to do is check for the presence of that in the request, and send back the appropriate response. To help with this, I normally create a little isHTMXRequest() function and use it like so: File: cmd/web/handlers.go package main ... func isHTMXRequest(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" } func (app *application) searchUsers(w http.ResponseWriter, r *http.Request) { query := r.FormValue("query") var matches []user if query == "" { matches = users } else { for _, u := range users { if strings.Contains(u.Name, query) || strings.Contains(u.Email, query) { matches = append(matches, u) } } } // Render the base template by default. template := "base" // But if the request is coming from HTMX, render the users:rows template instead. if isHTMXRequest(r) { template = "users:rows" } err := app.html.render(w, 200, matches, template, "pages/users.tmpl") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } If you restart the application and visit http://localhost:5051/users/search?query=leo again now, you should see a full HTML page containing only the matching user records. But there are a couple more things we need to do to finish this up. Because we're sending back different responses from searchUsers based on the value of the HX-Request header, we should also set a Vary: HX-Request on the response to tell any caches between our server and the client that responses may be different based on the value of this header. We could set the Vary: HX-Request header in searchUsers, but I think it's easier to just always set it on all responses in the render() function. It does mean that we'll be setting the Vary header on all responses — including those from our home handler and listUsers — which isn't strictly necessary and a little bit wasteful. But I think it's worth it to avoid having to remember setting the Vary header correctly in individual handlers, and the risk of bugs that forgetting it may cause. File: cmd/web/html.go func (h *htmlRenderer) render(w http.ResponseWriter, status int, data any, templateName string, additionalFiles ...string) error { ... w.Header().Add("Vary", "HX-Request") w.WriteHeader(status) buf.WriteTo(w) return nil } Lastly, we need to consider back-button behavior. Whenever HTMX adds an entry to the browser history (which it will do when you use the hx-push-url or hx-boost attributes), it caches the HTML for the complete page in the browser's local storage. When the user clicks the back button, this complete cached HTML page will be reshown to them. By default the HTMX cache stores up to 10 pages. If there is a cache miss (i.e. the user navigates back far enough that there is no longer a matching page in the cache), HTMX will resend the request to the server to refetch the content for that URL. The problem is that this request will include the HX-Request: true header, and our application will send back a partial HTML response rather than the complete HTML page that it needs to redisplay to the user. To deal with this scenario, there is an historyRestoreAsHxRequest setting which controls whether HTMX will include the HX-Request: true header when it's sending a request because of a cache miss. The documentation advises: This should always be disabled when using HX-Request header to optionally return partial responses. So let's go ahead and configure HTMX so that the historyRestoreAsHxRequest setting is false. There are a couple of ways you can configure HTMX settings, but I generally like to set them in a meta tag in the base.tmpl file like so: File: assets/html/base.tmpl {{define "base"}} {{template "page:title" .}} Example website {{template "page:content" .}} {{end}} Now that this is set, if there is a cache miss when using the back button, HTMX will send a request to our application without the HX-Request: true header, and our application will send back the complete HTML page to reshow to the user. Managing redirects Using HTMX in your application normally reduces the need for 3xx redirects. For example, when submitting a form you can often send back an HTML partial with a success message that can be swapped into the page, rather than using the standard Post/Redirect/Get pattern and redirecting to a confirmation page. But still, there may be times that you want to redirect to a completely new page after a form submission coming from HTMX. A common example would be redirecting to a profile page after a successful login. Unfortunately, to achieve this you can't just send a regular 3xx response. The crux of the problem is that that browsers will automatically intercept and follow 3xx responses before HTMX has access to them — so HTMX never gets to see the 3xx response, only the final response after any redirects. It doesn't know that a redirect happened behind the scenes, and will just swap in the returned content like normal. Instead, if you want something that behaves more like a regular redirect, you need to send a 2xx response along with the HX-Redirect header. For example, when you include the response header HX-Redirect: /foo/bar, it will make HTMX tell the browser to navigate to /foo/bar, triggering a full-page reload. Importantly the HX-Request: true header will not be included in the request to /foo/bar. But you also need to handle the situation where the original request might not be coming from HTMX — especially if you are using progressive enhancement so that your application still works if JavaScript is disabled or HTMX doesn't load correctly. In that case, it's important to fallback to sending a regular 3xx response from your Go handler, rather than the 2xx response and HX-Redirect header. Putting this together, I normally create a redirect() helper which leverages the isHTMXRequest() function we made earlier and looks like this: func redirect(w http.ResponseWriter, r *http.Request, url string, code int) { if isHTMXRequest(r) { w.Header().Set("HX-Redirect", url) w.WriteHeader(http.StatusNoContent) return } http.Redirect(w, r, url, code) } And, for example, in the scenario of wanting to redirect to a /profile page after a successful login, I use it in my Go handlers like this: redirect(w, r, "/profile", http.StatusSeeOther) As I mentioned above, using HX-Redirect will trigger a full-page reload. But there is another option — the HX-Location header — which makes HTMX mimic the behavior of a redirect without a full-page reload. It essentially makes HTMX fetch the HTML for the provided URL, swap it into the HTML body, and add a new entry to the browser history. Importantly, when fetching the HTML the HX-Request: true header is used. At first glance, using HX-Location might seem preferable because it doesn't make a full-page reload, which gives your application a smoother more SPA-like experience. But it's a problem if the route that you are redirecting to uses the HX-Request: true header to conditionally send HTML partials. The handler has no way of telling whether the request is coming from HTMX following a HX-Location redirect (in which case it should send a full-page response) or from a 'normal' HTMX request (in which case it should return a partial). Unfortunately, unlike history restore requests, HTMX doesn't provide a setting to disable the HX-Request: true header when redirecting. So, most of the time I think it's easier and safer to use HX-Redirect and accept the downside of a full-page reload. But... if you are careful and structure your application so that the routes you redirect to only ever return full HTML pages, you may want to change the redirect() helper to use HX-Location instead like so: func redirect(w http.ResponseWriter, r *http.Request, url string, code int) { if isHTMXRequest(r) { w.Header().Set("HX-Location", url) w.WriteHeader(http.StatusNoContent) return } http.Redirect(w, r, url, code) } Managing errors If our demo application returns a 4xx or 5xx response, by default HTMX will not swap in the response. Instead it leaves the DOM as-is, and logs an error message in the console. If you'd like to see this in action, go ahead and change the gopher handler to render a "partial:image:missing" template (which doesn't exist). This should cause our application to error and send a 500 status code and a plaintext "Internal Server Error" response to the client. File: cmd/web/handlers.go func (app *application) gopher(w http.ResponseWriter, r *http.Request) { err := app.html.render(w, http.StatusOK, 100, "partial:image:missing") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } If you run the application and click the "Wanna see a cute gopher?" button, now nothing on the screen will change, but in your developer tools network tab you'll see the 500 response and a record of the problem in the console, like so: In most cases, this isn't ideal. If an application is sending back an error message, I normally want the user to actually see this message rather than having the operation fail silently for them (or at least, silently unless they have developer tools open 😉). And also in most cases, I want to display any error message from a 4xx or 5xx response as full-page HTML (in the same way that it would be shown if we weren't using HTMX) by swapping it into the element rather than swapping it into the regular HTMX target. The only exception to this is the 422 Unprocessable Content status, which I typically use when sending back a form with validation errors in it. In this case, I want HTMX to swap the returned content into the target element as normal. Luckily, you can use the HTMX responseHandling setting to configure different behavior for different responses codes. I normally configure this so that: For 204 No Content responses, no action is taken and no changes are made to the DOM. For 422 Unprocessable Content responses, HTMX swaps the response content into the target as normal. For all other 4xx and 5xx responses, HTMX swaps the returned content into the element. For any other response, HTMX swaps the response content into the target as normal. And just like before, I normally configure this via the HTMX configuration meta tag like so: File: assets/html/base.tmpl {{define "base"}} {{template "page:title" .}} Example website {{template "page:content" .}} {{end}} With that change made, if you restart the application and click the button again, you should now see the "Internal Server Error" message shown as a full-page response, like so: Note: If I ever want to swap an error message into a target that isn't the element, then I use the response target extension to override the above settings for a specific interaction and swap into a specific target. But... I find that the above settings are a good starting default. Current browser URL Because HTMX makes AJAX requests and swaps in responses without changing the browser URL (unless you use the hx-boost, hx-push-url or hx-replace-url attributes), the request URL that we see in our Go handlers when accessing r.URL may be different to the one that the user is seeing in their browser. Occasionally, there are times when I want to know in my Go handler exactly what URL the user is currently seeing in their browser. Fortunately, HTMX sends this information with each request in the HX-Current-URL header. Usually I make another little function to help with this, which parses the HX-Current-URL value and returns it as a url.URL, falling back to returning r.URL if no HX-Current-URL header is present. Like so: func browserURL(r *http.Request) (*url.URL, error) { cu := r.Header.Get("HX-Current-URL") if cu != "" { return url.Parse(cu) } return r.URL, nil } Additional HTMX configuration Lastly, there are a few other HTMX configuration settings that I normally change from the default values. I tend to disable the HTMX cache completely by setting historyCacheSize to 0. Caching pages in local storage is a source of bugs and security issues, so I think it's simpler and better to just disable it completely. If you do this, no pages will be cached in local storage, and when the user clicks the back button HTMX will send a request to the server to refetch the HTML. It's worth noting that caching in local storage will also be disabled by default in future versions of HTMX for the same reasons. I prefer to disable HTMX attribute inheritance by setting disableInheritance to true. I think it's clearer and lowers the risk of bugs or unintended behavior when HTMX attributes are always declared explicitly. Again, it's worth noting that attribute inheritance will also be disabled by default in future versions of HTMX. I also disable HTMX indicator styles by setting includeIndicatorStyles to false. For consistency, I prefer not to have HTMX injecting styles, and would rather define any indicator styles alongside my other CSS rules. By default there is no timeout on HTMX requests, and they will wait as long as necessary for the server to respond. It's project-specific, and depends on how I'm handling timeouts and deadlines in my Go application, but sometimes I may also use the timeout setting to set a default timeout (in milliseconds) on the HTMX end. All in all, the starting point for my HTMX configuration settings normally looks like this: Page-specific layouts Let's finish up this post with a final note about HTML templates in larger applications. For some applications, having a base template along with page-specific templates might not be enough. You might also want 'layout' templates that sit between the base template and your page-specific content — for example, you might want to use a layout template for your admin-area pages that is different to the rest of your regular application pages. The patterns that we've talked about in this post can be extended fairly easily to accommodate this. For example, you can change the base template to insert a "layout" template in the body element instead of the page-specific content directly: {{define "base"}} {{template "page:title" .}} ... {{template "layout" .}} {{end}} Then you could create an assets/html/layouts/admin.tmpl file containing the common 'layout' markup for the admin pages: {{define "layout"}} Admin area Users Orders {{template "page:content" .}} {{end}} And then you can specify which layout template you want to use in your Go handlers as part of the call to render(). Like so: func (app *application) adminOrders(w http.ResponseWriter, r *http.Request) { err := app.html.render(w, 200, nil, "base", "layouts/admin.tmpl", "pages/admin-orders.tmpl") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } }
- Go Naming Conventions: A Practical GuideMar 24, 2026
Choosing the right names in your codebase is an important (and sometimes difficult!) part of programming in Go. It's a small thing that makes a big difference — good names make your code clearer, more predictable, and easier to navigate; bad names do the opposite. Go has fairly strong conventions — and a few hard rules — for naming things. In this post we're going to explain these rules and conventions, provide some practical tips, and demonstrate some examples of good and bad names in Go. If you're new to the language, all this information might feel like a lot to take in, but it'll quickly become second nature with a bit of practice 😊 Identifiers Let's start with the hard rules for identifiers. By identifiers, I mean the names that you use for the variables, constants, types, functions, parameters, struct fields, methods and receivers in your code. Identifiers can contain unicode letters, digits, and underscores only. Identifiers cannot begin with a digit. You cannot use any of the following Go keywords as identifiers: break default func interface select case defer go map struct chan else goto package switch const fallthrough if range type continue for import return var So long as you stick to those three rules, any identifier name is technically valid and your code will compile all OK. But there are a bunch of other guidelines that it's good practice to follow: You should use camelCase for unexported identifiers, or PascalCase for exported identifiers. Don't use alternative casing variants like snake_case, Pascal_Snake_Case, SCREAMING_SNAKE_CASE or ALLUPPERCASE. Words that are acronyms or initialisms (like API, URL or HTTP) should use a consistent case within the identifier. So, for example, apiKey or APIKey is good, but ApiKey is not. This rule also applies to ID when it is used as shorthand for the words "identity" or "identifier" — so that means write userID rather than userId. Although all unicode letters are allowed, using non-ASCII letters can often make your code harder to read and more awkward to write, and it's rare to see them used. Unless you have a really appropriate use-case, you should stick to using ASCII letters in identifiers. For example, use pi instead of π, use beta instead of β, use naiveBayes instead of naïveBayes. To prevent confusion for readers and potential bugs, avoid choosing identifiers that clash with Go's builtin types. So, for example, don't create variables with names like int, bool or any. Similarly, avoid creating functions with names that clash with Go's builtin functions. So, for example, don't create functions with names like min, max, len or clear. Generally, avoid including the type in identifiers — for example, don't use names like fullNameString, scoreInt or float64Amount. The main exception to this is when you have to convert a variable to a different type, and you want to distinguish between the original variable and the one containing the converted value. In this situation, including the type in the identifier is a common and acceptable way to distinguish between the two. For example, code like this is OK: userID := 42 userIDStr := strconv.Itoa(userID) Where possible, try to avoid choosing identifiers that clash with the standard library package names. This is a 'softer' convention than the others because the standard library steals a lot of good identifier names — such as json, js, mail, user, csv, path, filepath, log, regexp, time and url — and sometimes it can be hard to come up with decent alternatives. However, you definitely should avoid creating identifiers that clash with the package names that your code is actually importing and using. So, for example, if you are writing code that imports the url and net/mail packages, then don't use the words url and mail as identifiers in that code. Here are a few examples of good and bad identifier names: Bad Reason Better order.total := 99.99func load-user() Punctuation not allowed orderTotal := 99.99func loadUser() const 3rdParty = "x"func 2FactorAuth() Cannot start with a digit const thirdParty = "x"func twoFactorAuth() max_value := 10func Fetch_user() Non-standard casing maxValue := 10func FetchUser() type HttpClient struct{}func parseXml() Inconsistent acronym casing type HTTPClient struct{}func parseXML() func GetSessionId()type OrderId string ID should be all caps func GetSessionID()type OrderID string résuméCount := 2const Σ = 100 Non-ASCII letters resumeCount := 2const sum = 100 func clear()int := cache.Internal() Clashes with builtin types or functions func clearQueue()data := cache.Internal() intCount := 42resultSlice := []int{} Type included in name count := 42results := []int{} type json struct{}var log = newLogger() Clashes with stdlib package names type payload struct{}var logger = newLogger() Exported and unexported identifiers Identifiers in Go are case-sensitive. For example, the identifiers apiKey, apikey and APIKey are all different. As you probably already know, when an identifier starts with a capital letter it is exported — that is, it's visible to code outside of the package it's declared in. This means that the casing of the first letter is significant. It impacts the behavior of your codebase. In turn, this means that you shouldn't start identifiers with a capital letter just because they look nice — you should only start them with a capital letter if you want them to be exported and accessible to code outside the package they are declared in. As a tip, try to write packages using unexported identifiers by default. Only export them when you actually have a need to. Typically, the less you export, the easier it is to refactor code within a package without affecting other parts of your codebase. There's a nice quote from The Pragmatic Programmer, which I'll adapt slightly for the Go nomenclature: Write shy code - packages that don't reveal anything unnecessary to other packages and don't rely on other packages' implementations. As a second tip, it's very rare for a main package to be imported by anything, so the identifiers in it should normally all be unexported and start with a lowercase letter. The most frequent exception to this is when you need to export a struct field so that it's visible to packages that use reflection to work, like encoding/json, encoding/gob or github.com/jmoiron/sqlx. Identifier length and descriptiveness In general, the further away that an identifier is used from where it is declared, the more descriptive the name should be. If you have an identifier which is narrow in scope and only used close to where it is declared, it's generally OK to use a short and not-very-descriptive name. For example, if you're naming something that is only used in a small for loop, range block, or very short function, then using short or even single letter names is very common in Go. But it you're naming something that has a larger scope, or is used far away from where it is declared, you should use a name that clearly describes what the thing represents. Here is a nice example that Dave Cheney gave as part of his Practical Go presentation: type Person struct { Name string Age int } func AverageAge(people []Person) int { if len(people) == 0 { return 0 } var count, sum int for _, p := range people { sum += p.Age count += 1 } return sum / count } In this code, within the short range block we use the identifier p to represent a value in the people slice — the range block is so small and tight that using a single letter name is clear enough. In contrast, the count and sum variables are declared, then used inside the range, then again in the return statement. Giving them more descriptive names makes it immediately clearer what the code is doing and what they represent, compared to single letter names like c and s. But these variables are only ever used inside the AverageAge function, so giving them even-more-descriptive names like peopleCount and agesSum would be unnecessarily verbose. It's not an exact science, but when writing Go code you are encouraged to use the right length identifier — sometimes that might be long and descriptive, sometimes it might be short and terse. Naming packages The hard rules for package names are the same as for identifiers: they can contain unicode letters, numbers and underscores, must not begin with a number, and must not be a Go keyword. But in practice, the conventions for naming a package are much tighter. Conventionally: Package names should contain lower case ASCII letters and numbers only. Because package names will need to be typed out a lot when writing code, the name should ideally be short, easy to type, and reflect the contents of the package. Often simple one-word nouns (like orders, customer and slug) work well. If you want to use more than one word in the package name, you should concatenate the words all in lowercase with no separator. So, for example, ordermanager is a good package name — orderManager or order_manager are not. If a package name feels too long, it can be OK to use abbreviations in the name. You can see this in some of the standard library package names, like expvar (instead of exportedvariables) and strconv (instead of stringconversion). To prevent conflicts and confusion, try to avoid using the same name as commonly-used standard library packages. Package names with the prefix . or _ are 'invisible' to Go and completely ignored when you run go build, go run, go test etc. So don't start your package name with these characters, unless you specifically want them to be ignored. Directories with the names vendor, testdata and internal have a special meaning in Go, so to avoid any confusion or bugs, don't use these words as package names. Avoid using 'catch all' package names like common, util, helpers, types or interfaces, which don't really give any clue to what the package contains. For example, does a package called helpers contain validation helpers, formatting helpers, SQL helpers? A mix of all the above? You can't guess from just the name alone. As well as not being clear, these kind of 'catch all' names provide little natural boundary or scope, which can lead to the package becoming a dumping ground for lots of different things. In turn the package may become imported and used throughout your codebase — which increases the risk of import cycles and means that changes to the package potentially affect the whole codebase, rather than just a specific part of it. In other words, catch all package names encourage creating packages which have a large 'blast radius'. If you find yourself wanting to create a utils or helpers package, ask yourself if you can break up the contents into smaller packages with a specific focus and clearer names instead. Bad Reason Better package 3rdpartypackage 2fa Cannot start with a digit package thirdpartypackage twofa package OrderManagerpackage order_manager Non-standard casing / separators package ordermanager package opackage stuff Too vague and not descriptive package orderspackage slug package ordermanagementsystem Too long / hard to type package orderspackage ordermgr package urlpackage mail Clashes with stdlib package names package linkspackage mailer package _cachepackage .hidden Ignored by Go tooling package cachepackage hidden package internalpackage vendorpackage testdata Special directory names in Go package internalauthpackage supplier package utilspackage helpers Catch-all names with unclear scope package validationpackage formatting Naming files In an ideal world, a .go filename should summarize what the file contains, be one word long, and all in lowercase. Some examples of good filenames from the standard library net/http package are cookie.go, server.go and status.go. If you can't think of a good one-word name, and want to use two or more words, there is no clear convention for how those words should be separated. Even in the Go standard library itself there isn't consistency. Sometimes underscores are used to separate the words in filenames (like routing_index.go and routing_tree.go), and other times they are concatenated with nothing between them (like batchcursor.go, textreader.go and reverseproxy.go). Because there isn't a strong convention around this, I recommend just picking one of these two approaches and sticking to it consistently within a codebase. Personally, I think it's better to concatenate words with nothing between them (like routingindex.go), and reserve the underscore character for only when you want to use a special filename suffix. Talking of which, there are some filename prefixes and suffixes that have a special meaning in Go. You should avoid using these in your filenames unless you want to trigger the special behavior. Specifically: Like packages, filenames with the prefix . or _ are 'invisible' to the Go tooling and completely ignored when you run go build, go run, go test etc. Files with the suffix _test.go are only run by the go test tool. They are ignored when using go run or go build. Files with any of the following suffixes will only be included when compiling for that specific operating system: _aix.go, _android.go, _darwin.go, _dragonfly.go, _freebsd.go, _illumos.go, _ios.go, _js.go, _linux.go, _netbsd.go, _openbsd.go, _plan9.go, _solaris.go, _wasip1.go, _windows.go. Similarly, files with any of the following suffixes will only be included when compiling for that specific architecture: _386.go, _amd64.go, _arm.go, _arm64.go, _loong64.go, _mips.go, _mips64.go, _mips64le.go, _mipsle.go, _ppc64.go, _ppc64le.go, _riscv64.go, _s390x.go, _wasm.go. Avoiding chatter When you are naming exported functions, try to avoid repeating the name of the package they are declared in. For example, if you have a package called customer, then function names like NewCustomer() or CustomerOrders() would be 'chattery' and unnecessarily repeat the word 'customer' when you call them from outside the package — like customer.NewCustomer() and customer.CustomerOrders(). Calling the functions New() and Orders() is sufficient and reads better at the call site — like customer.New() and customer.Orders(). The same advice also applies to exported types. For example, if you want to represent an address or phone number in a customer package, it's sufficient and less chattery to name the types Address and PhoneNumber rather than CustomerAddress and CustomerPhoneNumber. Bad Reason Better customer.NewCustomer()customer.CustomerOrders() Chattery function call customer.New()customer.Orders() customer.CustomerAddresscustomer.CustomerPhoneNumber Chattery type reference customer.Addresscustomer.PhoneNumber Note: It's common to want to declare an exported type that shares the same name as the package. For example, a customer package might export a Customer type that represents an individual customer. We would then reference this type in other packages by writing customers.Customer. This is obviously chattery, but it's hard to avoid this repetition without giving either the package or type a name that makes it less clear. So in practice, this is something that you'll see a lot. For example, in the standard library the time package has a Time type, which you reference by typing time.Time, the context package contains a Context type which you reference by typing context.Context, and the regexp package contains a Regexp type which you reference by typing regexp.Regexp. Similar to function and type names, method names should ideally not 'chatter' too much when calling them. For example, if you are writing methods on a Token type, for example, it's probably OK to call a method Validate() rather than ValidateToken(), or IsExpired() rather than IsTokenExpired(). Method receivers When you are creating methods, it is conventional for the method receiver to have a short name, normally between 1 and 3 characters long and often an abbreviation of the type that the method is implemented on. For example, if you are implementing a method on a Customer type, an idiomatic receiver name would be something like c or cus. Or if you were implementing a method on a HighScore type, a good receiver name would be hs. The Go code review comments advise against using generic names like this, self or me for the receiver. Also, you should be consistent with the receiver name. All methods on the same type should use the same receiver name — don't use c for one method and cus for another. type Order struct { Items int } // Good: uses a short receiver func (o *Order) Validate() bool { return o.Items > 0 } // Bad: uses a longer receiver name func (order *Order) Validate() bool { return order.Items > 0 } // Bad: uses a generic receiver name func (self *Order) Validate() bool { return self.Items > 0 } Getter and setter methods on structs Typically, it is not necessary to create 'getter' and 'setter' methods on struct types in Go. Instead, you just access the struct field directly to read or change the data. The major exception to this is when you have a struct with an unexported field, but want to provide a way to get or set the field value from outside the package. To do this, you need to create exported 'getter' and 'setter' methods that read and write to the unexported field. When doing this, it is conventional to prefix the setter method name with Set, but not prefix the getter method name with Get. Like so: type Customer struct { address string } func (c *Customer) Address() string { return c.address } func (c *Customer) SetAddress(addr string) { c.address = addr } Interfaces By convention, interfaces that only contain one method should be named by the method name plus an '-er' suffix or similar. For example: type Speaker interface { Speak() string } type Authorizer interface { Authorize(ctx context.Context, action string) error } type Authenticator interface { Authenticate(ctx context.Context) (User, error) } The Go standard library has quite a few examples of interfaces that follow this convention, such as io.Reader, io.Writer and fmt.Stringer. Also note that the guidance to avoid including the type in the name still applies to interfaces. Don't give your interfaces names like UserInterface or OrderInterface unless you really can't think of a decent alternative. Breaking from conventions There are rare occasions when breaking a convention can actually make your code clearer — and in my view, it can be OK to do that... especially if it's in a private codebase worked on by a small team. For example, a couple of years ago I was working on a Go program that synchronizes data between some other external systems. In this project, I ended up breaking some of the Go naming conventions around casing and separators — instead using exactly the same identifiers that the external systems used. This actually made the intent of the program clearer, and more immediately obvious what was being synchronized with what. But the vast majority of the time, you should endeavour to follow the naming rules and conventions we've discussed in this post. They exist for good reasons: they make your code more predictable and consistent, easier for other Gophers to quickly understand, and reduce the risk of certain bugs.
- A modern approach to preventing CSRF in GoOct 14, 2025
Go 1.25 introduced a new http.CrossOriginProtection middleware to the standard library — and it got me wondering: Have we finally reached the point where CSRF attacks can be prevented without relying on a token-based check (like double-submit cookies)? Can we build secure web applications without bringing in third-party packages like justinas/nosurf or gorilla/csrf? And I think the answer now may be a cautious “yes” — so long as a few important conditions are met. If you want to skip the explanations and just want to see what those conditions are, you can click here. The http.CrossOriginProtection middleware The new http.CrossOriginProtection middleware works by checking the values in a request's Sec-Fetch-Site and Origin headers to determine where the request is coming from. It will automatically reject any non-safe requests that are not from the same origin, and will send the client a 403 Forbidden response. The http.CrossOriginProtection middleware has some limitations, which we'll discuss in a moment, but it is robust and simple to use, and a great addition to the standard library. How it works Modern browsers automatically include the Sec-Fetch-Site header in requests. This header indicates the relationship between the origin of the page making the request, and the origin of the page being requested. Two pages are considered to have the same origin if their scheme, hostname and port (if present) exactly match, in which case the browser will include a Sec-Fetch-Site: same-origin header in the request. If the two pages don't have the same origin, the Sec-Fetch-Site header will be set to a different value to indicate this, and http.CrossOriginProtection will reject the request. If no Sec-Fetch-Site header is present, http.CrossOriginProtection will fall back to checking the Origin header. Specifically, it will compare the request's Origin header and Host header to see if they match. If they don't match, then it considers the request to not be from the same origin and it will reject it. If neither the Sec-Fetch-Site nor Origin headers are present, then it assumes the request is not coming from web browser and will always allow the request to proceed. The checks described above only take place on requests with non-safe methods (POST, PUT, etc.). Requests with safe HTTP methods (GET, OPTIONS, etc.) are always allowed to proceed. If you're interested in learning more about the design and decision making behind http.CrossOriginProtection, the original proposal by Filippo Valsorda is an excellent read. At its simplest, you can use it like this: File: main.go package main import ( "fmt" "log/slog" "net/http" "os" ) func main() { mux := http.NewServeMux() mux.HandleFunc("/", home) slog.Info("starting server on :4000") // Wrap the mux with the http.NewCrossOriginProtection middleware. err := http.ListenAndServe(":4000", http.NewCrossOriginProtection().Handler(mux)) if err != nil { slog.Error(err.Error()) os.Exit(1) } } func home(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "Hello!") } If you want, it's also possible to configure the behavior of http.CrossOriginProtection. Configuration options include being able to add trusted origins (from which cross-origin requests are allowed), and the ability to use a custom handler for rejected requests instead of the default 403 Forbidden response. When I've wanted to customize the behavior, I've been using a pattern like this: File: main.go package main import ( "fmt" "log/slog" "net/http" "os" ) func main() { mux := http.NewServeMux() mux.HandleFunc("/", home) slog.Info("starting server on :4000") err := http.ListenAndServe(":4000", preventCSRF(mux)) if err != nil { slog.Error(err.Error()) os.Exit(1) } } func preventCSRF(next http.Handler) http.Handler { cop := http.NewCrossOriginProtection() cop.AddTrustedOrigin("https://foo.example.com") cop.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("CSRF check failed")) })) return cop.Handler(next) } func home(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "Hello!") } Limitations The big limitation of http.CrossOriginProtection is that it is only effective at blocking requests from modern browsers. Your application will still be vulnerable to CSRF attacks coming from older (generally pre-2020) browsers which do not include at least one of the Sec-Fetch-Site or Origin headers in requests. Right now, browser support for the Sec-Fetch-Site header is at 92%, and for Origin it is 95%. So — in general — relying on http.CrossOriginProtection is not sufficient as your only protection against CSRF. It's also important to note that the Sec-Fetch-Site header is only sent when your application has a "trustworthy origin" — which basically means that your application needs to be using HTTPS in production (or localhost in development) for http.CrossOriginProtection to work to its full potential. And you should also be aware that when no Sec-Fetch-Site header is present in a request, and it falls back to comparing the Origin and Host headers, the Host header does not include the scheme. This limitation means that http.CrossOriginProtection will wrongly allow cross-origin requests from http://{host} to https://{host} when there is no Sec-Fetch-Site header present but there is an Origin header. To mitigate this risk, you should ideally configure your application to use HTTP Strict Transport Security (HSTS). Enforcing TLS 1.3 Looking into this got me wondering... What if you're already planning to use HTTPS and enforce TLS 1.3 as the minimum supported TLS version? Could you be confident that all web browsers which support TLS 1.3 also support either the Sec-Fetch-Site or Origin headers? As far as I can tell from the MDN compatibility data and tables from Can I Use, the answer is "yes" for (almost) all major browsers. If you enforce TLS 1.3 as the minimum version: Older browsers which don't support TLS 1.3 simply won't be able to connect to your application. For the modern major browsers that do support TLS 1.3 and can connect, you can be confident that at least one of the Sec-Fetch-Site or Origin headers are supported — and therefore http.CrossOriginProtection will work effectively. The only exception to this I can see is Firefox v60-69 (2018-2019), which did not support the Sec-Fetch-Site header and did not send the Origin header for POST requests. This means that http.CrossOriginProtection will not work effectively to block requests originating from that browser. Can I Use puts usage of Firefox v60-69 at 0%, so the risk here appears very low — but there are probably some computers somewhere in the world still running it. Also, we only have this information for the major browsers — Chrome/Chromium, Firefox, Edge, Safari, Opera and Internet Explorer. But of course, other browsers exist. Most of them are forks of Chromium or Firefox and therefore will likely be OK, but there's no guarantee here and it is hard to quantify the risk. So if you use HTTPS and enforce TLS 1.3, it's a huge step forward in making sure that http.CrossOriginProtection can work effectively. However, there remains a non-zero risk that comes from Firefox v60-69 and non-major browsers, so you may want to add some defense-in-depth and utilize SameSite cookies too. We'll talk more about SameSite cookies in a moment, but first we need to take a quick detour and discuss the difference between the terms origin and site. Cross-site vs cross-origin In the world of web specifications and web browsers, cross-site and cross-origin are subtly different things, and in a security context like this it's important to understand the difference and be exact about what we mean. I'll quickly explain. Two websites have the same origin if they share the exact same scheme, hostname, and port (if present). So https://example.com and https://www.example.com are not the same origin because the hostnames (example.com and www.example.com) are different. A request between them would be cross-origin. Two websites are 'same site' if they share the same scheme and registerable domain. Note: The registerable domain is the part of the hostname just before (and including) the effective TLD. Here are a few examples: For https://www.google.com/ the TLD is com and the registerable domain is google.com. For https://login.mail.ucla.edu the TLD is edu and the registerable domain is ucla.edu. For https://www.gov.uk, the TLD is gov.uk and the registerable domain is www.gov.uk. You can find the complete list of effective TLDs here. So https://example.com, https://www.example.com and https://login.admin.example.com are all considered to be the same site because the scheme (https) and registerable domain (example.com) are the same. A request between these would not be considered to be cross-site, but it would be cross-origin. Note: Some browser versions use a different definition of same-site which doesn't require the same scheme, only the same registrable domain. For these browser versions, https://admin.example.com and http://blog.example.com would also be considered same-site. Nowadays, this is typically referred to as schemaless same-site, but in historical versions or documentation it may have just been called same-site. So what are the points that I'm building up to here? Go's http.CrossOriginProtection middleware is accurately and appropriately named. It blocks cross-origin requests. It's more strict than it would be if it only blocked cross-site requests, because it also blocks requests from other origins under the same site (i.e. registrable domain). This is useful because it helps to prevent a situation where your janky-not-been-updated-in-the-last-decade WordPress blog at https://blog.example.com is compromised and used to launch a request forgery attack at your important https://admin.example.com website. When most people — myself included — casually talk about "CSRF attacks", what we are referring to most of the time is actually cross-origin request forgery, not just cross-site request forgery. It's a shame that CSRF is the commonly used and known acronym to describe this family of attacks, because most of the time CORF would be more accurate and appropriate. But hey! That's the messy world we live in. For the rest of this post though, I'll use the term CORF instead of CSRF when that is exactly what I mean. SameSite cookies The SameSite cookie attribute has generally been supported by web browsers since 2017, and by Go since v1.11. If you set the SameSite=Lax or SameSite=Strict attributes on a cookie, that cookie will only be included in requests to the same site that set it. In turn, that prevents cross-site request forgery attacks (but not cross-origin attacks from within the same site). There is some good news here — all major browsers that support TLS 1.3 also fully support SameSite cookies, with no exceptions that I can see. So if you enforce TLS 1.3, you can be confident that all the major browsers using your application will respect the SameSite attribute. This means that by using SameSite=Lax or SameSite=Strict on your cookies, you cover off the risk of cross-site request forgeries from Firefox v60-69 that we talked about earlier. Putting it all together If you combine using HTTPS, enforcing TLS 1.3 as the minimum version, using SameSite=Lax or SameSite=Strict cookies appropriately, and using the http.CrossOriginProtection middleware in your application, as far as I can see there are only two unmitigated CSRF/CORF risks from major browsers: CORF attacks from within the same site (i.e. from another subdomain under your registrable domain) in Firefox v60-69. CORF attacks from a HTTP version of your origin, from browsers that do not support the Sec-Fetch-Site header. For the first of these risks, if you don't have any other websites under your registrable domain, or you're confident that the websites are secure and uncompromised, then this might be a risk that you're willing to accept given the extremely low usage of Firefox v60-69. For the second, if you don't support HTTP on your origin at all (including redirects) then this isn't something you need to worry about. Otherwise, you can mitigate the risk by including a HSTS header on your HTTPS responses. At the start of this article, I said that not using a token-based CSRF check might be OK under certain conditions. So let's run through what those are: Your application uses HTTPS and enforces TLS 1.3 as the minimum version. You accept that users with older browsers will not be able to connect to your application at all. You follow good-practice and never change important application state in response to requests with the safe methods GET, HEAD, OPTIONS or TRACE. You use both the http.CrossOriginProtection middleware and SameSite=Lax or SameSite=Strict cookies. It's important to still use SameSite cookies for general defense in depth, but more specifically to mitigate CSRF attacks from Firefox v60-69. Because of the unprotected risk of a same-site CORF attack from Firefox v60-69, you either don't have any other websites under your registrable domain, or you're confident that they're secure and uncompromised. There is either no HTTP version of your application origin at all, or you include a HSTS header on your HTTPS responses. Finally, you are willing to accept the difficult-to-quantify risk of CSRF/CORF attacks from non-major browsers that support TLS 1.3 but don't support the Origin header, Sec-Fetch-Site header or SameSite cookies. Does any such browser exist? I don't know, and I'm not sure there's a way to answer that question with 100% confidence. So you'll need to do your own risk assessment here, and it's a risk that you probably only want to accept if your application is a low-value target and the impact of a successful CSRF/CORF attack is both isolated and minor.
- The 9 Go test assertions I use (and why)Aug 22, 2025
A few weeks ago Anton Zhiyanov published the blog post Expressive tests without testify/assert. It's a good and well thought-out post, and I recommend giving it a read if you haven't already. In the post, Anton makes the argument for not using packages like testify/assert for your test assertions, and instead creating your own minimal set of assertion helpers to use in your tests. In fact, so minimal that there are only 3 helpers he uses: AssertEqual, AssertErr and AssertTrue. There are some people who would argue that even this is too much, and that you shouldn't use assertion functions in your tests at all. In fact, the Go Code Review Comments for Tests states that using assert packages should be avoided, which we'll talk about in more detail at the end of this post. But I agree with the general direction of Anton's thinking. I do use assertion functions — and I've always preferred to write my own rather than using a third-party package. Over time I've whittled them down to a standard collection of nine basic functions that I use: Assertion What it checks Equal(got, want) Checks that got and want are equal NotEqual(got, want) Checks that got and want are not equal True(got) Checks that got is true False(got) Checks that got is false Nil(got) Checks that got is nil NotNil(got) Checks that got is not nil ErrorIs(got, want) Checks that got is an error that wraps or equals want ErrorAs(got, target) Checks that got is an error that can be assigned to target via errors.As MatchesRegexp(got, pattern) Checks that got matches the regex pattern Between these nine functions, I'm able to easily do the vast majority of the checks that I want in my tests. Here are some examples from a web application that I'm currently working on: assert.Equal(t, w.StatusCode, http.StatusTeapot) assert.Equal(t, w.Header().Get("X-Custom-Header"), "custom-value") assert.NotEqual(t, updatedSession.token, originalSession.token) assert.True(t, defaultShutdownPeriod > defaultWriteTimeout) assert.True(t, strings.Contains(buf.String(), "level=ERROR")) assert.False(t, strings.Contains(string(decodedCookieValue), "this is a test value")) assert.Nil(t, err) assert.ErrorIs(t, err, sql.ErrNoRows) assert.MatchesRegexp(t, user.HashedPassword, `^\$2a\$12\$[./0-9A-Za-z]{53}$`) From the perspective of someone reading the code, I think it's quite easy to understand what these assertions are checking — even if you've never seen them before. And this might be personal preference, but when writing tests I actually prefer having only a small number of basic assertion functions to remember and pick from, rather than lots of very specific ones. If there is a complex check, which can't be done in a single line as part of the function call, I normally create an additional function and use it in conjunction with the True or False assertions. For example, when testing a web application, I will sometimes want to check if an HTML response body contains a specific HTML node (based on a CSS selector), so I will make a containsHTMLNode() function and then use it in my tests like this: assert.True(t, containsHTMLNode(t, res.Body, `meta[name="page"][content="home"]`)) assert.True(t, containsHTMLNode(t, res.Body, `form[method="POST"][action="/login"]`)) In theory, these assertion helpers could be reduced further. For example, the Nil(got) and NotNil(got) functions could be dropped in favour of using Equal(got, nil) and NotEqual(got, nil). Or MatchesRegexp(got, pattern) could be dropped in favour of using True() to check that a got value matches a specific regexp pattern. But these are checks I use often enough that I like having a specific assertion function for them. Go back to Anton's post for a moment, he effectively combines the assert.Nil(), assert.NotNil(), assert.ErrorIs() and assert.ErrorAs() functions that I have into a single AssertErr() function. The exact kind of check that is carried out by AssertErr() depends on what arguments you pass, or don't pass, to it. However, I prefer assertion functions to be responsible for checking one specific thing. I think it's less prone to mistakes, as well as clearer for a reader exactly what is being checked. Overall, I'm happy to have a few more assertion functions in exchange for some extra convenience, clarity and precision. Here's the complete code that I'm currently using for those functions: package assert import ( "errors" "reflect" "regexp" "testing" ) func Equal[T any](t *testing.T, got, want T) { t.Helper() if !isEqual(got, want) { t.Errorf("got: %v; want: %v", got, want) } } func NotEqual[T any](t *testing.T, got, want T) { t.Helper() if isEqual(got, want) { t.Errorf("got: %v; expected values to be different", got) } } func True(t *testing.T, got bool) { t.Helper() if !got { t.Errorf("got: false; want: true") } } func False(t *testing.T, got bool) { t.Helper() if got { t.Errorf("got: true; want: false") } } func Nil(t *testing.T, got any) { t.Helper() if !isNil(got) { t.Errorf("got: %v; want: nil", got) } } func NotNil(t *testing.T, got any) { t.Helper() if isNil(got) { t.Errorf("got: nil; want: non-nil") } } func ErrorIs(t *testing.T, got, want error) { t.Helper() if !errors.Is(got, want) { t.Errorf("got: %v; want: %v", got, want) } } func ErrorAs(t *testing.T, got error, target any) { t.Helper() if got == nil { t.Errorf("got: nil; want assignable to: %T", target) return } if !errors.As(got, target) { t.Errorf("got: %v; want assignable to: %T", got, target) } } func MatchesRegexp(t *testing.T, got, pattern string) { t.Helper() matched, err := regexp.MatchString(pattern, got) if err != nil { t.Fatalf("unable to parse regexp pattern %s: %s", pattern, err.Error()) return } if !matched { t.Errorf("got: %q; want to match %q", got, pattern) } } func isEqual[T any](got, want T) bool { if isNil(got) && isNil(want) { return true } if equalable, ok := any(got).(interface{ Equal(T) bool }); ok { return equalable.Equal(want) } return reflect.DeepEqual(got, want) } func isNil(v any) bool { if v == nil { return true } rv := reflect.ValueOf(v) switch rv.Kind() { case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: return rv.IsNil() } return false } Are assertion functions an anti-pattern? As I mentioned at the start of this post, the Go Wiki says that using assert packages should be avoided. It starts with this example of some 'bad' test code: assert.IsNotNil(t, "obj", obj) assert.StringEq(t, "obj.Type", obj.Type, "blogPost") assert.IntEq(t, "obj.Comments", obj.Comments, 2) assert.StringNotEq(t, "obj.Body", obj.Body, "") And suggests this as a 'good' alternative: if obj == nil || obj.Type != "blogPost" || obj.Comments != 2 || obj.Body == "" { t.Errorf("AddPost() = %+v", obj) } Let's quickly run through the arguments in the Go Wiki for these approaches being good and bad. [The bad code] either stops the test early (if assert calls t.Fatalf or panic) or omits interesting information about what the test got right There are packages out there (such as testify/require) that will stop a test early on the first assertion failure, and when they do you lose information about what subsequent checks would have passed. But not all of them do this, and if you make your own helpers for test assertions, you control what they do. You can call t.Errorf() to record the failure and continue the test if you want to. [The bad code] also forces the assert package to create a whole new sub-language instead of reusing the existing programming language (Go itself) I think this is a valid point and worth keeping in mind. Sometimes it can be frustrating to have to learn how a third-party package works, and to remember its API and syntax. And if the package is used in a project that lots of people work on, you're forcing all of them to spend the time to learn it. Sometimes it's easier to just read and write Go code that uses the standard library — even if it means you end up with more lines of code. But that said, I'm not sure that having a small number of basic assertion functions adds that much overhead... even for new people working on a codebase. Does having 3 assertion helpers like Anton, or 9 like me, really count as creating a whole new sub-language? Even if you argue that it does, it's a very small sub-language. Assert libraries make it too easy to write imprecise tests I think this is a good point in some — but not all — cases. If you're using a package that does different kinds of assertion checks in the same function (e.g. depending on the type of the argument passed to it, or the presence or not of a variadic argument) then yes, it's possible to see how it potentially increases the risk of bugs or a loss of precision in your tests. But if the assertion function checks one thing and one thing only, I don't see how it would be less precise. The assert.Equal() function that I use is a good example of this. It's imprecise because it checks whether both values are nil or whether both are the same based on an Equals() method or they are equal according to reflect.DeepEqual(). The ors introduce a subtle loss of precision that wouldn't exist if we were only checking one of those things. However, the go-cmp/cmp.Equal function, which the Go Wiki goes on to recommend using for equality checks, is imprecise in a similar way. I'm not sure that the assert.Equal() code above is really any worse in this sense. [Assert libraries] inevitably end up duplicating features already in the language, like expression evaluation, comparisons, sometimes even more. Yes. And I think this is why my preference is to use a small set of very basic assertions, like assert.Equal() and assert.True(). It means that I can write assertions like assert.True(t, len(mySlice) > 3) or assert.False(t, strings.Contains(name, "admin")) using the normal Go functions and operators. I don't get stuck down a rabbit hole implementing helpers like assert.SliceLengthGreaterThan() or assert.StringDoesNotContain() for every kind of check I need to do. On the flip side, the Go Wiki doesn't provide balance and mention the upsides of using assertion helpers, which is a shame. In terms of developer experience, I suspect that even the most hardened Gopher would agree that writing three lines of code like this: assert.Equal(t, w.StatusCode, http.StatusTeapot) assert.ErrorIs(t, err, sql.ErrNoRows) assert.True(t, defaultShutdownPeriod > defaultWriteTimeout) Is a faster and more enjoyable experience than writing the equivalent code like this: if w.StatusCode != http.StatusTeapot { t.Errorf("got %d; want %d", w.StatusCode, http.StatusTeapot) } if !errors.Is(err, sql.ErrNoRows) { t.Errorf("got error %q; want error to be or wrap %q", err.String(), sql.ErrNoRows) } if defaultShutdownPeriod Not only is the code shorter, but it takes away the cognitive overhead of having to write a failure message for each check. Which is both good and bad. I find it good because it frees up my brain to focus on arguably the most important thing — which is the logic of the test and what is being tested. When I'm thinking about test logic, I don’t want to get distracted trying to craft a perfect failure message, or having to look up for the 100th time whether it is got before want or want before got. Being able to type out assertions quickly, without losing my train of thought, is something that I really value and appreciate. And it's bad, because having useful and thoughtful failure messages can make debugging a problem easier. Getting a failure message that reads like this: --- FAIL: TestServerConfiguration (0.00s) — FAIL: TestServerConfiguration/Default_timeouts_are_reasonable (0.00s) server_test.go:24: default shutdown period 5s must be greater than default write timeout 10s Is much better than using an assert.True() helper and getting a failure message like this: --- FAIL: TestServerConfiguration (0.00s) — FAIL: TestServerConfiguration/Default_timeouts_are_reasonable (0.00s) server_test.go:22: got: false; want: true In this second example, all you have to go on to start debugging the failure is the file name and line number of the check — it doesn't even include the value that caused the check to fail. I do think this, in particular, is a genuine downside of the assert.True() and assert.False() helpers that I use. Summary I've found that the nine assertion helpers I shared above have worked well for me in a variety of projects — and they might work well for you too. But ultimately whether they are the right fit depends on your preferences, your team members, and the specific project. If you use a small collection of basic assertion functions like this, rather than a large third-party package, then I think that most of the criticisms that the Go Wiki makes of assert packages don't really apply. But you still need to accept that the failure messages printed by assertion functions may not be as helpful as a tailored, specific, failure message would be. On the plus side, they make for a good developer experience when writing tests. I particularly appreciate that they are quick to write and allow my mind to stay focused on the logic of what I'm testing. And on balance, anything that encourages me to write more tests is probably a good thing : )
- How to manage configuration settings in Go web applicationsJun 30, 2025
When I'm building a web application in Go, I prefer to use command-line flags to pass configuration settings to the application at runtime. But sometimes, the client I'm working with wants to use environment variables to store configuration settings, or the nature of the project means that storing settings in a TOML, YAML or JSON file is a better fit. And of course that's OK — it makes sense to be flexible and vary how configuration is managed based on the specific needs of a project and/or client. So, in this tutorial, I want to share the patterns that I use for parsing configuration settings — whether they come from flags, environment variables or files — and explain how I pass the settings onwards to where they are needed in the rest of the web application code. I'll also end with a short discussion about the relative pros and cons of the different approaches. It's a fairly detailed post, so here are the shortcut links for quick reference: Using command-line flags Using environment variables Using configuration files Passing settings to where they are needed Discussion Example code To illustrate the patterns in the rest of this tutorial, let's pretend that we have a web application where we want to configure the following five settings: Setting Type Description port int The port number the web application listens on verboseLogging bool Enables detailed request and error logging requestTimeout time.Duration Maximum duration to wait for a request to complete basicAuthUsername string Username required for HTTP Basic Authentication basicAuthPassword string Password required for HTTP Basic Authentication Regardless of where the configuration settings are coming from (flags, environment variables or a file), I'm quite strict about keeping all the code related to configuration settings isolated in one place, and reading in the configuration setting values right at the start of the program, before doing almost anything else. Most of the time, I prefer to store all the configuration setting values in a single config struct, like so: type config struct { port int verboseLogging bool requestTimeout time.Duration basicAuth struct { username string password string } } I like this because it feels very clear — all the configuration settings are contained in a single struct, along with their appropriate Go type, and you can easily see at a glance what configuration settings the application expects and supports. Using command-line flags As I mentioned at the start of this tutorial, using command-line flags with the standard library flag package is my preferred approach to managing configuration settings. With this approach, you explicitly pass the configuration values as part of the command when running the program. For example: $ go run main.go -port=9999 -verbose-logging=true -request-timeout=10s -basic-auth-username=admin -basic-auth-password="secr3tPa55word" In your Go code, you define a specific command-line flag using syntax like this: flag.IntVar(&cfg.port, "port", 4000, "The port number the web application listens on")` In this example code, we define a command-line flag named port that accepts an integer value and stores it at the location pointed to by the &cfg.port pointer. It will have a default value of 4000 if no corresponding -port flag is provided when starting the application, and the final parameter is a description that will be displayed when a user runs the program with the -help flag. Importantly, after you've defined all the command-line flags for your application, you need to call the flag.Parse() function to actually read in the values from the command-line arguments. Let's put this together in a very simple application that reads the command-line flag values into a config struct, and then prints them out. File: main.go package main import ( "flag" "fmt" "time" ) // The config struct holds all configuration settings for the application. type config struct { port int verboseLogging bool requestTimeout time.Duration basicAuth struct { username string password string } } func main() { // Create a new config instance. var cfg config // Define the command-line flags. Notice that we define these so that the values // are read directly into the appropriate config struct field, and set sensible default // values for each of them. flag.IntVar(&cfg.port, "port", 4000, "The port number the web application listens on") flag.BoolVar(&cfg.verboseLogging, "verbose-logging", false, "Enables detailed request and error logging") flag.DurationVar(&cfg.requestTimeout, "request-timeout", 5*time.Second, "Maximum duration to wait for a request to complete") flag.StringVar(&cfg.basicAuth.username, "basic-auth-username", "", "Username required for HTTP Basic Authentication") flag.StringVar(&cfg.basicAuth.password, "basic-auth-password", "", "Password required for HTTP Basic Authentication") // Parse the flags with the flag.Parse function. This is important! flag.Parse() // Print all configuration settings. fmt.Printf("Port: %d\n", cfg.port) fmt.Printf("Verbose Logging: %t\n", cfg.verboseLogging) fmt.Printf("Request Timeout: %v\n", cfg.requestTimeout) fmt.Printf("Basic Auth Username: %s\n", cfg.basicAuth.username) fmt.Printf("Basic Auth Password: %s\n", cfg.basicAuth.password) } If you're following along, go ahead and run the application with your own values in the command-line flags. You should see the same values printed out by the application, like so: $ go run main.go -port=9999 -verbose-logging=true -request-timeout=30s -basic-auth-username=admin -basic-auth-password="secr3tPa55word" Port: 9999 Verbose Logging: true Request Timeout: 30s Basic Auth Username: admin Basic Auth Password: secr3tPa55word If you don't provide a value for a specific flag, the application will revert to using the default value you specified. For example, if you don't provide a -port flag it will default to the value of 4000, like so: $ go run main.go -basic-auth-username=admin -basic-auth-password="secr3tPa55word" Port: 4000 Verbose Logging: false Request Timeout: 5s Basic Auth Username: admin Basic Auth Password: secr3tPa55word Help text One of the great things about the standard library flag package is the support for automatic help text. If you run your application with the flag -help, it will list all the available flags for the application, along with their accompanying help text and default values if appropriate. Like so: $ go run main.go -help Usage of /tmp/go-build2103583960/b001/exe/main: -basic-auth-password string Password required for HTTP Basic Authentication -basic-auth-username string Username required for HTTP Basic Authentication -port int The port number the web application listens on (default 4000) -request-timeout duration Maximum duration to wait for a request to complete (default 5s) -verbose-logging Enables detailed request and error logging Boolean flags For boolean flags, if you want to pass a value of true you can simply include the flag name without assigning a value. The following two commands are equivalent: $ go run main.go -verbose-logging=true $ go run main.go -verbose-logging In contrast, you always need to use -flag=false if you want to set a boolean flag value to false. Dashes You can use one or two dashes in front of a flag name, both work identically. The standard library flag package does not support 'short' flags, and the number of dashes has no effect on the behavior or any special meaning. So it's just a matter of personal taste which you use. The following two commands are equivalent: $ go run main.go -verbose-logging -request-timeout=30s $ go run main.go --verbose-logging --request-timeout=30s Invalid flags If you try to pass an invalid value as a command-line flag, the application will automatically exit with an error message and the help text for reference. For example, if you try to pass a non-integer value in the -port flag, the parsing would fail and the output would look like this: $ go run main.go -port=foobar invalid value "foobar" for flag -port: parse error Usage of /tmp/go-build2103583960/b001/exe/main: -basic-auth-password string Password required for HTTP Basic Authentication -basic-auth-username string Username required for HTTP Basic Authentication -port int The port number the web application listens on (default 4000) -request-timeout duration Maximum duration to wait for a request to complete (default 5s) -verbose-logging Enables detailed request and error logging exit status 2 Similarly, if you try to use a flag that as not been defined, the application will automatically exit with an error message and the help text. For example: $ go run main.go -foobar=baz flag provided but not defined: -foobar ...etc Custom flag types The flag package provides functions for reading command-line flag values into the following Go types: bool, int, int64, uint, uint64, float64, string and time.Duration. If you want to parse a command-line flag value into another Go type (such as time.Time or []string), you have a few different options. The simplest approach is to use the flag.Func() function, which I've written about here. Or you can also make your own custom type that implements the flag.Value or encoding.TextUnmarshaler interfaces, and define the flag using either the flag.Var() or flag.TextVar() functions respectively. I've shared a gist demonstrating how to do this here. Alternatively, there are third-party packages (such as spf13/viper) that you can use, which automatically support parsing command-line flags into a wider range of Go types. Personally, I've never felt it necessary to use these, but YMMV. Flagsets Lastly, if you want you can create flagsets, which act like a 'container' for a distinct set of command-line flags. It's rare that I need to use flagsets in a web application, but I do often use them when building CLI applications with multiple subcommands. There's a good tutorial about how to use flagsets here. Using environment variables First, I'll start by saying that you can use environment variables in conjunction with command-line flags if you want. Simply set your environment variables as normal, and use them in the command when starting your application. Like so: $ export VERBOSE_LOGGING="true" $ export REQUEST_TIMEOUT="30s" $ go run main.go -verbose-logging=$VERBOSE_LOGGING -request-timeout=$REQUEST_TIMEOUT But if you don't want to do this, you can read the values from environment variables directly into your Go code using the os.Getenv() function. This will return the value of the environment variable as a string, or the empty string "" if the environment variable doesn't exist. You can also use the os.LookupEnv() function to check whether a specific environment variable exists or not. To help read values from environment variables, I like to create an internal/env package containing some helper functions that convert the environment variable string to the appropriate Go type, and optionally set a default value for if the environment variable doesn't exist (just like command-line flags). For example: File: internal/env/env.go package env import ( "fmt" "os" "strconv" "time" ) func GetInt(key string, defaultValue int) int { value, exists := os.LookupEnv(key) if !exists { return defaultValue } intValue, err := strconv.Atoi(value) if err != nil { panic(fmt.Errorf("environment variable %s=%q cannot be converted to an int", key, value)) } return intValue } func GetBool(key string, defaultValue bool) bool { value, exists := os.LookupEnv(key) if !exists { return defaultValue } boolValue, err := strconv.ParseBool(value) if err != nil { panic(fmt.Errorf("environment variable %s=%q cannot be converted to a bool", key, value)) } return boolValue } func GetDuration(key string, defaultValue time.Duration) time.Duration { value, exists := os.LookupEnv(key) if !exists { return defaultValue } durationValue, err := time.ParseDuration(value) if err != nil { panic(fmt.Errorf("environment variable %s=%q cannot be converted to a time.Duration", key, value)) } return durationValue } func GetString(key string, defaultValue string) string { value, exists := os.LookupEnv(key) if !exists { return defaultValue } return value } In some projects, I use a twist on these helper functions and panic if a specific environment variable isn't set, rather than returning a default value. For example: func MustGetInt(key string) int { value, exists := os.LookupEnv(key) if !exists { panic(fmt.Errorf("environment variable %s must be set", key)) } intValue, err := strconv.Atoi(value) if err != nil { panic(fmt.Errorf("environment variable %s=%q cannot be converted to an int", key, value)) } return intValue } Note: If you're looking at this code and thinking that it is bad practice to call panic() rather than returning an error, you'd be right. But in the context where these helpers are used, it seems a reasonable thing to do. If our application can't load the configuration settings that it needs to operate on startup, it can't reasonably continue, and depending on what has failed to load it may not even be safe or sensible to execute any further code. Terminating the application by panicking doesn't seem inappropriate in this scenario. I've written more about this in the post When is it OK to panic in Go?. Using those helper functions in your application then looks a bit like this: File: main.go package main import ( "fmt" "time" "your-project/internal/env" ) type config struct { port int verboseLogging bool requestTimeout time.Duration basicAuth struct { username string password string } } func main() { var cfg config cfg.port = env.GetInt("PORT", 4000) cfg.verboseLogging = env.GetBool("VERBOSE_LOGGING", false) cfg.requestTimeout = env.GetDuration("REQUEST_TIMEOUT", 5*time.Second) cfg.basicAuth.username = env.GetString("BASIC_AUTH_USERNAME", "") cfg.basicAuth.password = env.GetString("BASIC_AUTH_PASSWORD", "") fmt.Printf("Port: %d\n", cfg.port) fmt.Printf("Verbose Logging: %t\n", cfg.verboseLogging) fmt.Printf("Request Timeout: %v\n", cfg.requestTimeout) fmt.Printf("Basic Auth Username: %s\n", cfg.basicAuth.username) fmt.Printf("Basic Auth Password: %s\n", cfg.basicAuth.password) } If you'd like to try this out, go ahead and add the necessary environment variables to your /etc/environment or ~/.profile files, or export them in your shell, and try running the application again. You should see the configuration settings reflected in the output, or any default values for ones that you didn't set. $ export PORT="9999" $ export VERBOSE_LOGGING="false" $ export BASIC_AUTH_USERNAME="admin" $ export BASIC_AUTH_PASSWORD="secr3tPa55word" $ go run main.go Port: 9999 Verbose Logging: false Request Timeout: 5s Basic Auth Username: admin Basic Auth Password: secr3tPa55word Using .env files If you're working on multiple projects on the same development machine (and not using separate containers for each project), it can become awkward to manage environment variables and avoid clashes across the projects. Rather than setting environment variables in /etc/environment or ~/.profile, a fairly common workaround is to create an .env file in your project containing the environment variables, like so: File: .env export PORT=5000 export VERBOSE_LOGGING=true export REQUEST_TIMEOUT=10s export BASIC_AUTH_USERNAME=admin export BASIC_AUTH_PASSWORD=secr3tPa55word Then you can source the .env file to export the variables in the current terminal session and run your Go application: $ source .env $ go run main.go Port: 5000 Verbose Logging: true Request Timeout: 10s Basic Auth Username: admin Basic Auth Password: secr3tPa55word Alternatively, if you don't want to keep running the source command, you can use the joho/godotenv package to automatically load the values from the .env file into the environment when your application starts up. Using configuration files The third option that I sometimes use is configuration files, which store all the settings in a single file on-disk. I normally only use these in projects where there are a lot of configuration settings, and loading them all via command-line flags would be onerous and error-prone. Or also, if the configuration settings are complex, with a deeply nested 'structure' to them. There are a lot of different formats that you can use for configuration files, such as TOML or YAML — or even JSON. They all have different advantages and disadvantages, and you'll be hard-pressed to find one that everybody agrees is 'perfect'. But whatever format you choose, there is probably a Go package that you can use to automatically parse values from the file into a config struct for you. For example, let's say that you want to use TOML and have a configuration file that looks like this: File: config.toml # Server configuration port = 4000 verbose_logging = true request_timeout = "10s" # Basic authentication settings [basic_auth] username = "admin" password = "secr3tPa55word" You can use the BurntSushi/toml package to read the file and unpack the contents to a config struct like so: File: main.go package main import ( "fmt" "log" "time" "github.com/BurntSushi/toml" ) // Make sure the struct fields are exported, so that the BurntSushi/toml package // can write to them, and use struct tags to map the TOML key/value pairs to the // appropriate struct field. type config struct { Port int `toml:"port"` VerboseLogging bool `toml:"verbose_logging"` RequestTimeout time.Duration `toml:"request_timeout"` BasicAuth struct { Username string `toml:"username"` Password string `toml:"password"` } `toml:"basic_auth"` } func main() { var cfg config // Load configuration settings from the config.toml file. metadata, err := toml.DecodeFile("config.toml", &cfg) if err != nil { log.Fatalf("error loading configuration: %v", err) } // Check for any undecoded keys in the config.toml file. if len(metadata.Undecoded()) > 0 { log.Fatalf("unknown configuration keys: %v", metadata.Undecoded()) } fmt.Printf("Port: %d\n", cfg.Port) fmt.Printf("Verbose Logging: %t\n", cfg.VerboseLogging) fmt.Printf("Request Timeout: %v\n", cfg.RequestTimeout) fmt.Printf("Basic Auth Username: %s\n", cfg.BasicAuth.Username) fmt.Printf("Basic Auth Password: %s\n", cfg.BasicAuth.Password) } Notice that in this code we're making use of the metadata returned by the toml.DecodeFile() function to check if any settings were not decoded successfully — which should help to catch typos or invalid keys in the TOML file. Passing settings to where they are needed Getting the configuration settings into the config struct, wherever they come from, is the first half of the puzzle. The second part is getting those settings to where you need them in your Go code. There are many different ways to approach this, and no single 'right' way. For small or medium sized web applications, I often use a pattern of creating an application struct which contains all the dependencies that my HTTP handlers need, and I implement the handlers as methods on the application struct. To make the configuration settings available to the HTTP handlers, I simply include the config struct as a field in application. For example: File: main.go package main import ( "flag" "fmt" "log/slog" "net/http" "os" "time" ) type config struct { port int verboseLogging bool requestTimeout time.Duration basicAuth struct { username string password string } } // The application struct contains the dependencies for the handlers, including // the config struct type application struct { config config logger *slog.Logger } func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) var cfg config flag.IntVar(&cfg.port, "port", 4000, "The port number the web application listens on") flag.BoolVar(&cfg.verboseLogging, "verbose-logging", false, "Enables detailed request and error logging") flag.DurationVar(&cfg.requestTimeout, "request-timeout", 5*time.Second, "Maximum duration to wait for a request to complete") flag.StringVar(&cfg.basicAuth.username, "basic-auth-username", "", "Username required for HTTP Basic Authentication") flag.StringVar(&cfg.basicAuth.password, "basic-auth-password", "", "Password required for HTTP Basic Authentication") flag.Parse() app := &application{ config: cfg, logger: logger, } mux := http.NewServeMux() mux.HandleFunc("/", app.home) // Use the port configuration setting logger.Info("starting server", "port", cfg.port) err := http.ListenAndServe(fmt.Sprintf(":%d", cfg.port), mux) if err != nil { logger.Error(err.Error()) os.Exit(1) } } func (app *application) home(w http.ResponseWriter, r *http.Request) { // Use the verboseLogging configuration setting if app.config.verboseLogging { app.logger.Info("handling request", "method", r.Method, "path", r.URL.Path) } fmt.Fprintf(w, "Hello!") } If you run this application with the -verbose-logging flag, and make a HTTP request to localhost:4000, you should see the details of the request in the log output, similar to below — demonstrating that the config setting is correctly available to the handler. $ go run main.go -verbose-logging time=2025-06-27T14:15:40.230+02:00 level=INFO msg="starting server" port=4000 time=2025-06-27T14:15:48.705+02:00 level=INFO msg="handling request" method=GET path=/ In larger applications where I want to define my handlers outside of package main, or pass the config struct to functions in other packages, I normally define an exported Config struct in an internal/config package, and pass this around as necessary. For example, let's say that you have a project structure like so: ├── go.mod ├── go.sum ├── main.go └── internal ├── config │ └── config.go └── handlers └── home.go Then the contents of those .go files would look something like this: File: internal/config/config.go package config import "time" type Config struct { Port int VerboseLogging bool RequestTimeout time.Duration BasicAuth struct { Username string Password string } } File: internal/handlers/home.go package handlers import ( "fmt" "log/slog" "net/http" "your-project/internal/config" ) func Home(cfg config.Config, logger *slog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if cfg.VerboseLogging { logger.Info("handling request", "method", r.Method, "path", r.URL.Path) } fmt.Fprintf(w, "Hello!") } } File: main.go package main import ( "flag" "fmt" "log/slog" "net/http" "os" "time" "your-project/internal/config" "your-project/internal/handlers" ) func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) var cfg config.Config flag.IntVar(&cfg.Port, "port", 4000, "The port number the web application listens on") flag.BoolVar(&cfg.VerboseLogging, "verbose-logging", false, "Enables detailed request and error logging") flag.DurationVar(&cfg.RequestTimeout, "request-timeout", 5*time.Second, "Maximum duration to wait for a request to complete") flag.StringVar(&cfg.BasicAuth.Username, "basic-auth-username", "", "Username required for HTTP Basic Authentication") flag.StringVar(&cfg.BasicAuth.Password, "basic-auth-password", "", "Password required for HTTP Basic Authentication") flag.Parse() mux := http.NewServeMux() mux.HandleFunc("/", handlers.Home(cfg, logger)) // Use the port configuration setting logger.Info("starting server", "port", cfg.Port) err := http.ListenAndServe(fmt.Sprintf(":%d", cfg.Port), mux) if err != nil { logger.Error(err.Error()) os.Exit(1) } } Obviously I'm using command-line flags in these examples, but the same patterns work for environment variables or config files too — once the config struct is loaded with the data, it doesn't matter where it originally came from and the code patterns are the same. Discussion If you've been in the web development world for a long time and buy into the 12-factor app principles (which I generally do), you might think that the correct approach is "just use environment variables". But over the years I've come to the conclusion that they have some drawbacks: I've been bitten more times than I want by bugs that were ultimately a result of an unset or unexpected value in an environment variable — and I think that part of the problem here is that environment variables aren't readily and easily observable in the same way that the values in command-line flags or a configuration file are. If you're working on multiple projects on the same development machine (rather than working in separate containers for each project), you have to manage the lack of natural isolation between environment variables... you need to make sure that there aren't any naming clashes, and that (for example) application A isn't accidentally using the DB_PASSWORD setting intended for application B. I've also seen a lot of Go codebases where configuration settings are read in using os.Getenv() at the point in the code where they are needed. This makes discoverability difficult — it's hard to look at an application's code and easily see what the expected configuration settings are. You can mitigate these issues with some of the techniques that we've discussed in this tutorial. If you're strict about reading all the settings into a single config struct at application startup, that addresses the discoverability problem. If you create helpers like env.MustGetInt() which panic if an environment variable isn't set, that helps to eliminate bugs that exist due to missing environment variables. And you can work around some of the environment variable isolation problems in development by using a .env file — but at that point, it might be worth considering whether a configuration file might be more appropriate. One of the big reasons that I like to use command-line flags is that you get a lot of stuff for free. You get automatic -help text, automatic type conversions, the ability to set defaults, and it handles invalid inputs and undefined flags nicely. Also, it's always very clear what configuration values are being used — you either explicitly pass the values when starting the application, or the default values hardcoded into your Go codebase are used. On top of that, most other gophers will be familiar with the flag package and you don't need any third-party dependencies. When I'm using command-line flags, I typically set the default values to things that are appropriate for a development environment. This is mainly so I don't have to keep typing long commands to run the application when actively developing it. In terms of application secrets, like I mentioned earlier, there's nothing stopping you from storing a specific secret in an environment variable and using it in conjunction with a command-line flag if you want. For example, if you store a password for your database user in a DB_PASSWORD environment variable, you can include it as a command-line flag value when starting the application like so: $ go run main.go -db-user=web -db-password=$DB_PASSWORD Or, although it is a bit more 'magical', you could even use the environment variable as the default value: flag.StringVar(&cfg.db.password, "db-password", os.Getenv("DB_PASSWORD"), "Database user password") So, for all these reasons, I tend to prefer using command-line flags for configuration. The big exception to this is when there are a lot of configuration settings, and it would be awkward to pass them all via command-line flags, or the settings have a deeply nested 'structure' to them. In these cases, I think it can be more practical and maintainable to store the settings in a TOML or JSON configuration file, and load them on application startup like we demonstrated earlier.
- Organize your Go middleware without dependenciesApr 26, 2025
For many years, I've used third-party packages to help organize and manage middleware in my Go web applications. In small projects, I often used alice to create middleware 'chains' that I could reuse across multiple routes. And for larger applications, with lots of middleware and routes, I typically used a router like chi or flow to create nested route 'groups' with per-group middleware. But since Go 1.22 introduced the new pattern matching functionality for http.ServeMux, where possible I've tried to drop third-party dependencies from my routing logic and shift to using just the standard library. But going all-in on the standard library leaves a good question: how should we organize and manage middleware without using any third-party packages? Note: If you're not familiar with middleware in Go, I recommend reading this introduction to making and using middleware before continuing. Why is managing middleware a problem? If you have an application with only a few routes and middleware functions, the simplest thing to do is to wrap your handler functions with the necessary middleware on a route-by-route basis. A bit like this: // No middleware on this route. mux.Handle("GET /static/", http.FileServerFS(ui.Files)) // Both these routes use the requestID and logRequest middleware. mux.Handle("GET /", requestID(logRequest(http.HandlerFunc(home)))) mux.Handle("GET /article/{id}", requestID(logRequest(http.HandlerFunc(showArticle)))) // This route has the additional authenticateUser and requireAdminUser middleware. mux.Handle("GET /admin", requestID(logRequest(authenticateUser(requireAdminUser(http.HandlerFunc(showAdminDashboard)))))) This works, and requires no external dependencies, but you can probably imagine the downsides as the number of routes grows: There's repetition in the route declarations. It's a bit difficult to read and see which routes are using the same middleware at a glance. It feels error-prone — in a large application if you need to add, remove or reorder middleware across many routes it could be easy to miss out one of the routes and not spot the mistake. An alternative to alice As I briefly mentioned above, the alice package allows you to declare and reuse 'chains' of middleware. We could rewrite the example code above to use alice like so: mux := http.NewServeMux() // Create a base middleware chain. baseChain := alice.New(requestID, logRequest) // Extend the base chain with auth middleware for admin-only routes. adminChain := baseChain.Append(authenticateUser, requireAdminUser) // No middleware on this route. mux.Handle("GET /static/", http.FileServerFS(ui.Files)) // Public routes using the base middleware. mux.Handle("GET /", baseChain.ThenFunc(home)) mux.Handle("GET /article/{id}", baseChain.ThenFunc(showArticle)) // Admin routes with the additional auth middleware. mux.Handle("GET /admin", adminChain.ThenFunc(showAdminDashboard)) To me, this code feels quite a lot cleaner, and it largely mitigates the three problems that we talked about above. But if you don't want to introduce alice as a dependency, it's possible to leverage the slices.Backward function introduced in Go 1.23 and create your own chain type in a few simple lines of code: type chain []func(http.Handler) http.Handler func (c chain) thenFunc(h http.HandlerFunc) http.Handler { return c.then(h) } func (c chain) then(h http.Handler) http.Handler { for _, mw := range slices.Backward(c) { h = mw(h) } return h } You can then use this chain type in your route declarations like so: mux := http.NewServeMux() // Create a base middleware chain. baseChain := chain{requestID, logRequest} // Extend the base chain with auth middleware for admin-only routes. adminChain := append(baseChain, authenticateUser, requireAdminUser) mux.Handle("GET /static/", http.FileServerFS(ui.Files)) mux.Handle("GET /", baseChain.thenFunc(home)) mux.Handle("GET /article/{id}", baseChain.thenFunc(showArticle)) mux.Handle("GET /admin", adminChain.thenFunc(showAdminDashboard)) The syntax in this code isn't exactly the same as using alice, but it's pretty close, and in terms of behavior it's functionally the same. If you're interested in using this approach in your own codebase, I've made tests for the chain type available in this gist. An alternative to chi and similar routers In large applications, when I have lots-of-different-middleware being used on lots-of-different-routes, I've always found the route grouping functionality provided by routers like chi and flow to be a huge help. They basically allow you to create route groups with specific middleware, and these groups can be nested, with child groups 'inheriting' and extending the middleware of their parent groups. Let's take a look at an example using chi, which I think was the first router to support this style of route grouping functionality. r := chi.NewRouter() // No middleware on this route. r.Method("GET", "/static/", http.FileServerFS(ui.Files)) // Create a route group. r.Group(func(r chi.Router) { // Add the middleware for the group. r.Use(requestID) r.Use(logRequest) // The routes declared in the group will use this middleware. r.Get("/", home) r.Get("/article/{id}", showArticle) // Create a nested route group. Any routes in this group will use the // middleware declared in the group *and* the parent groups. r.Group(func(r chi.Router) { r.Use(authenticateUser) r.Use(requireAdminUser) r.Get("/admin", showAdminDashboard) }) }) But if you want to stick with the standard library, it doesn't take much to create your own router implementation that wraps http.ServeMux and supports middleware groups in a similar style: type Router struct { chain []func(http.Handler) http.Handler *http.ServeMux } func NewRouter() *Router { return &Router{ServeMux: http.NewServeMux()} } func (r *Router) Use(mw ...func(http.Handler) http.Handler) { r.chain = append(r.chain, mw...) } func (r *Router) Group(fn func(r *Router)) { subRouter := &Router{chain: slices.Clone(r.chain), ServeMux: r.ServeMux} fn(subRouter) } func (r *Router) HandleFunc(pattern string, h http.HandlerFunc) { r.Handle(pattern, h) } func (r *Router) Handle(pattern string, h http.Handler) { for _, mw := range slices.Backward(r.chain) { h = mw(h) } r.ServeMux.Handle(pattern, h) } And then you can use the Router type in your code like so: r := NewRouter() r.Handle("GET /static/", http.FileServerFS(ui.Files)) r.Group(func(r *Router) { r.Use(requestID) r.Use(logRequest) r.HandleFunc("GET /", home) r.HandleFunc("GET /article/{id}", showArticle) r.Group(func(r *Router) { r.Use(authenticateUser) r.Use(requireAdminUser) r.HandleFunc("GET /admin", showAdminDashboard) }) }) Again, complete tests for the Router type are available in this gist.
- When is it OK to panic in Go?Mar 31, 2025
If you've been working with Go for a while, you might be familiar with the Go proverb "don't panic". It's a pithy way of saying: "handle errors gracefully, or return them to the caller to handle gracefully, instead of passing errors to the built-in panic() function". And while "don't panic" is a great guideline that you should follow, sometimes it's taken to mean that you should no-way, never, ever call panic(). And I don't think that's true. The panic() function is a tool, and there are some rare times when it might be the appropriate tool for the job. In this post we'll talk through what panic() does and why it's generally better to avoid using it, discuss some scenarios where panicking can be appropriate, and finish with a few real-world examples. Panicking vs. returning errors Let's begin by creating a timeIn() function, which takes a IANA time zone name and returns the current time in that zone. In Go, if the timeIn() function encounters an error, the normal and idiomatic way to deal with it would be to return the error to the caller. Like so: package main import ( "fmt" "os" "time" ) func timeIn(zone string) (time.Time, error) { loc, err := time.LoadLocation(zone) if err != nil { return time.Time{}, err // Return any error from time.LoadLocation() } return time.Now().In(loc), nil } func main() { tz := "Europe/Wonderland" t, err := timeIn(tz) if err != nil { fmt.Println("Error:", err) os.Exit(1) } fmt.Println("Current time in", tz, "is", t) } $ go run main.go Error: unknown time zone Europe/Wonderland exit status 1 In theory, you could handle the potential error inside timeIn() by passing it to panic() — instead of returning it. Like this: package main import ( "fmt" "time" ) func timeIn(zone string) time.Time { loc, err := time.LoadLocation(zone) if err != nil { panic(err) // Call panic() with the error as the argument } return time.Now().In(loc) } func main() { tz := "Europe/Wonderland" t := timeIn(tz) fmt.Println("Current time in", tz, "is", t) } $ go run main.go panic: unknown time zone Europe/Wonderland goroutine 1 [running]: main.timeIn({0x4c2c7e?, 0x7d40fe626108?}) /tmp/main.go:11 +0xc5 main.main() /tmp/main.go:20 +0x2b exit status 2 When you call panic() in your Go code, it will do the following four things: Immediately stop normal execution of the code in the current function. Nothing after the call to panic() will be executed. Run any deferred functions for the current goroutine in reverse (LIFO) order. Print out panic: and the value you passed to the panic() function to os.Stderr, along with a stack trace for the current goroutine at the point panic() was called, . Terminate the program with exit code 2. Note: It's possible to recover panics by using the recover() function inside a deferred function, in which case step 2 will only be executed until the point of recovery, and steps 3 and 4 in the list above won't automatically happen. Explaining how recover() works is outside the scope of this blog post, but here's a good introduction and I also recommend watching this video for a discussion of some intricacies. Also note: The official documentation for panic() only goes as far as saying "the program is terminated with a non-zero exit code". As far as I can see, at the time of writing, the exit code following an unrecovered panic is always 2, but the documentation deliberately says "non-zero" to give wiggle-room for a potential change to a different exit code in the future. There's a discussion about this here. Why is panicking considered bad? The panic() function itself isn't intrinsically bad. In fact, what it does for you is really quite nice — the running of deferred functions, the printing of the stack trace... this is good stuff. It's more that returning errors is normally better. When you call panic() in a function, it always sets off the same fixed chain of events that we described above. Whereas if the function returns the error, the caller has full control over how that error is managed. It could be logged, presented to a user, the function could be retried, or the error could even be ignored. Alternatively, the error could be propagated again back up the call stack to the grandparent caller to manage. It all depends on the use case. When you return an error, the caller has control and flexibility to handle it in the most appropriate way. There are also some other benefits of returning errors: When propagating errors back up the call stack, you can optionally wrap them to provide additional context at each step. This extra context can make errors more informative and useful, and potentially make debugging easier than relying solely on the stack trace from a panic(). It's easier to write unit tests for a function when it returns errors. It's certainly not impossible to verify that a function panics when you expect it to during a test, but it is more awkward and less clear than just checking an error return value. If you're creating a package for other people to import and use, it's polite to return errors instead of panicking. Remember: a panic will terminate the running application, which people using your package may not expect or appreciate! It's better to return an error, and leave it up to the caller to decide what to do next. They can always call panic() with the error if they want. Finally, it's just the Go way. Errors are normally returned — it's what the Go standard library mostly does, and it's what other Gophers have come to expect as standard. By sticking with this convention, your code is more predictable and easier for other people to follow. So, returning errors (or handling them gracefully then-and-there) is almost always better. Which leaves us with the question, when is panicking the better option? When is panicking appropriate? To answer this, it's helpful to distinguish between what I'll call "operational errors" and "programmer errors" for the purpose of this post. By operational errors, we're talking about errors that you might reasonably expect to happen during the operation of your program. Some examples are errors caused by a database or network resource being temporarily unavailable, the permissions on a file being wrong, a timeout on a long-running operation, or invalid user input. These errors don't necessarily mean there is a problem with your program itself — in fact they're often caused by things outside the control of your program. Operational errors are to be expected. And because you know there's a chance they'll occur during normal operation, you should endeavour to return them to the caller and gracefully handle them in a way that makes the most sense for your program. Don't use panic() to manage them. By programmer errors, we're talking about errors which should "never" happen during the operation of your program — the kind of error that stems from a developer mistake, a logical flaw in your codebase, or trying to use another piece of code in an unsupported way. Ideally, you'd spot programmer errors during development or testing, rather than having them surface in production. And (hopefully!) they should be relatively rare. When you encounter a programmer error, it means that your program finds itself in an unexpected state. And in this scenario, calling panic() is much more commonly accepted as an appropriate thing to do. For all the good reasons that we talked about above, if it is possible to safely and gracefully manage the error by returning it up the call stack, then you should default to doing that still. But using panic() can be a good and appropriate choice when either: The error is truly unrecoverable (that is, there is no reasonable way to safely continue operating and handle the error more gracefully); or Returning the error would add an unacceptable amount of complexity or additional error handling code to the rest of your codebase — all for something that you never expect to see in production. You can see this logic play out in some of the Go standard library operations that trigger a panic. For instance: Dividing an integer or float by 0 Accessing an out-of-bounds index in slice or array Dereferencing a nil pointer Trying to use a nil map Unlocking a mutex that isn't locked Sending on a closed channel Defining two flags with the same name in the same flag.FlagSet Passing an integer or > 999 to http.ResponseWriter.WriteHeader() When a sync.WaitGroup counter drops below zero What do all these have in common? First, they're programmer errors. If any of these things happen, it's due to a logical mistake in your codebase or you trying to use a language feature or function in an unsupported way. These things shouldn't happen during normal operation in production. And if they returned an error, it would add an arguably unacceptable amount of extra error handling to everyone's Go code. Just imagine if you had to check for an error return value every time you use the / operator, access a value in a slice, or unlock a mutex. It would add a lot of overhead. So, in summary, it can be appropriate to use panic() to deal with programmer errors that are either unrecoverable or where returning an error would add an unacceptable amount of extra error handling to the rest of your codebase. Exactly what constitutes "an unacceptable amount" is your judgement call, based on your experience and particular codebase. And that's OK. There's no exact right or wrong answer here. On top of this, there are a couple of other scenarios where I think calling panic() can be appropriate: In a last-ditch 'guard clause' to prevent a particular operation happening when it shouldn't. If the panic ever gets executed, it indicates a bug in your program or violation of some internal business logic. When you don't want the program to continue and there are no better options for dealing with the error beyond calling panic(). Real-world examples and discussion By now I hope it's clear that panic() should be used sparingly and only when it really makes sense. Personally, probably about half of the codebases I work on don't call panic() at all, and even when they do, it's only in a few places. So with that said, here are a few real-life examples from recent codebases I've worked on. Example one Here's an example from a web application, where we have some code to retrieve a user value from the HTTP request context. type contextKey string const userContextKey = contextKey("user") func contextGetUser(r *http.Request) user.User { user, ok := r.Context().Value(userContextKey).(user.User) if !ok { panic("missing user value in request context") } return user } In this particular application, the code is structured in such a way that the contextGetUser() function is only ever called when we logically expect there to be a user value in the request context. In this application, a missing value is firmly an programmer error and indicates that there is something wrong with the codebase. Yes, contextGetUser() could return an error instead of panicking. The error is certainly recoverable — the caller could cease further operations, log the error and send the user a 500 Internal Server Error response. But this function gets called a lot, and it felt like returning an error would introduce excessive error handling for something that we should never see during normal operation. On balance, using panic() here felt appropriate. Example two Here's another example from the same application: func getEnvInt(key string, defaultValue int) int { value, exists := os.LookupEnv(key) if !exists { return defaultValue } intValue, err := strconv.Atoi(value) if err != nil { panic(err) } return intValue } In this application, getEnvInt() is a helper function used to read a value from an environment variable and convert it to an int. If the conversion fails, then it panics. At first glance, this might not seem like a suitable place to use panic(). An error when trying to convert a specific environment variable to an int seems like something outside of our program's control — an operational error. And it is. But in this case, the getEnvInt function is used (and only used) right at the start of the program to load configuration settings from the environment, like so: httpPort := getEnvInt("HTTP_PORT", 3939) At this early stage of the program, the logger (which also happens to rely on environment settings) hasn't been initialized. Since the program can't run without valid configuration values, and there's no proper logger available yet to handle errors gracefully, there aren't any other good options on the table for managing this error. Resorting to panic() feels like a reasonable choice. It fits the scenario of you don't want the program to continue and there are no better options for dealing with the error. Note: I could have made the getEnvInt() function return an error, and had the caller itself call panic(). But it would have generated additional error handling for basically the same end result, so on balance it made sense to panic from within getEnvInt(). Example three This is an example of where I've previously used panic() in a guard clause. var safeChars = regexp.MustCompile("^[a-z0-9_]+$") type SortValues struct { Column string Ascending bool } func (sv *SortValues) OrderBySQL() string { if !safeChars.MatchString(sv.Column) { panic("unsafe sort column: " + sv.Column) } if sv.Ascending { return fmt.Sprintf("ORDER BY %s ASC", sv.Column) } return fmt.Sprintf("ORDER BY %s DESC", sv.Column) } In this particular application, there was a need to generate SQL queries with dynamic ORDER BY parameters based on user input. Unfortunately, SQL doesn't support placeholder parameters in ORDER BY clauses, so we have to use string interpolation to insert the column name and sort direction into the query instead. The SortValues type holds the user-provided column name and sort direction, and its OrderBySQL() method returns a string like ORDER BY title ASC. By the time that the OrderBySQL() method is called, one of the upstream functions should have already validated the SortValues.Column value against a whitelist of allowed column names. But if a bug, or oversight, ever caused that validation step to be missed, the application would be vulnerable to a SQL injection attack via the user-provided column name. So, as a last-ditch mitigation, we use a panicking guard clause in OrderBySQL() to ensure that the SortValues.Column value only contains 'safe' characters (a to z, 0 to 9, and underscores). We never expect this check to fail, so returning an error from OrderBySQL() seems like overkill. But if it ever did happen, it feels better to trigger a panic than risk compromising the database. Summary So, let's answer the title of this post: When is it OK to panic in Go? Your default should always be to return errors to the caller — or handle them gracefully then-and-there. "Don't panic" is a good guideline to almost always follow. But panic() isn't inherently bad, and using it is appropriate when: Your program encounters a programmer error and there is no way to manage it safely in a more graceful way. Your program encounters a programmer error and returning it to the caller would add an unacceptable amount of complexity or error handling to the rest of your codebase. You have a last-ditch 'guard clause' to prevent a particular operation happening when it shouldn't. Your program can't continue and there are simply no better options for dealing with the error in a more graceful way.
- How to manage tool dependencies in Go 1.24+Feb 20, 2025
One of my favourite features of Go 1.24 is the new functionality for managing developer tooling dependencies. By this, I mean tooling that you use to assist with development, testing, build, or deployment – such as staticcheck for static code analysis, govulncheck for vulnerability scanning, or air for live-reloading applications. Historically, managing these dependencies — especially in a team setting — has been tricky. The previous solutions have been to use a tools.go file or the go run pattern, but while these approaches work, they’ve always felt like workarounds with some downsides. With Go 1.24, there’s finally a better way. Adding tools to your module Using tools Listing tools Verifying tools Vendoring tools Upgrading and downgrading tools Removing tools Using a separate modfile for tools A quick example To demonstrate the new functionality, let's scaffold a simple module and add some application code. $ go mod init example.com go: creating new go.mod: module example.com $ touch main.go File: main.go package main import ( "fmt" "github.com/kr/text" ) func main() { wrapped := text.Wrap("This is an informational message that should be wrapped.", 30) fmt.Println(wrapped) } Now fetch the github.com/kr/text package and run the code. The output should look like this: $ go get github.com/kr/text go: downloading github.com/kr/text v0.2.0 go: added github.com/kr/text v0.2.0 $ go run . This is an informational message that should be wrapped. Adding tools to your module Go 1.24 introduces the -tool flag for go get, which you can use like this: go get -tool import_path@version This command will download the package specified by the import path (along with any child dependencies), store them in your module cache, and record them in your go.mod file. The @version part is optional – if you omit it, the latest version will be downloaded. Let's use this to add the latest versions of stringer and govulncheck to our module as developer tools, along with staticcheck version 0.5.1. $ go get -tool golang.org/x/tools/cmd/stringer go: downloading golang.org/x/tools v0.30.0 go: downloading golang.org/x/sync v0.11.0 go: downloading golang.org/x/mod v0.23.0 go: added golang.org/x/mod v0.23.0 go: added golang.org/x/sync v0.11.0 go: added golang.org/x/tools v0.30.0 $ go get -tool golang.org/x/vuln/cmd/govulncheck go: downloading golang.org/x/vuln v1.1.4 go: downloading golang.org/x/telemetry v0.0.0-20240522233618-39ace7a40ae7 go: downloading golang.org/x/sys v0.30.0 go: upgraded golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 => v0.0.0-20240522233618-39ace7a40ae7 go: added golang.org/x/vuln v1.1.4 $ go get -tool honnef.co/go/tools/cmd/staticcheck@v0.5.1 go: downloading honnef.co/go/tools v0.5.1 go: downloading golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 go: downloading github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c go: downloading golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa go: added github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c go: added golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 go: added honnef.co/go/tools v0.5.1 After running these, your go.mod file will now include a tool (...) section listing the tools you've added. The corresponding module paths and versions for all the dependencies will appear in the require (...) section and be marked as indirect: File: go.mod module example.com go 1.24.0 require ( github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect github.com/kr/text v0.2.0 // indirect golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect golang.org/x/mod v0.23.0 // indirect golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/telemetry v0.0.0-20240522233618-39ace7a40ae7 // indirect golang.org/x/tools v0.30.0 // indirect golang.org/x/vuln v1.1.4 // indirect honnef.co/go/tools v0.5.1 // indirect ) tool ( golang.org/x/tools/cmd/stringer golang.org/x/vuln/cmd/govulncheck honnef.co/go/tools/cmd/staticcheck ) Using tools Once added, you can run tools using the go tool command. From the command line To run a specific tool from the command line within your module, you can use go tool followed by the last non-major-version segment of the import path for the tool (which is, normally, just the name for the tool). For example: $ go tool staticcheck -version staticcheck 2024.1.1 (0.5.1) $ go tool govulncheck No vulnerabilities found. In a makefile The go tool command also works nicely if you want to execute tools from your scripts or Makefiles. To illustrate, let's create a Makefile with an audit task that runs staticcheck and govulncheck on the codebase. $ touch Makefile .PHONY: audit audit: go vet ./... go tool staticcheck ./... go tool govulncheck If you run make audit, you should see that all the checks complete successfully. $ make audit go vet ./... go tool staticcheck ./... go tool govulncheck No vulnerabilities found. With go:generate Let's also take a look at an example where we use the stringer tool in conjunction with go:generate to generate String() methods for some iota constants. File: main.go package main import ( "fmt" "github.com/kr/text" ) //go:generate go tool stringer -type=Level type Level int const ( Info Level = iota Error Fatal ) func main() { wrapped := text.Wrap("This is an informational message that should be wrapped.", 30) fmt.Printf("%s: %s\n", Info, wrapped) } The important thing here is the //go:generate line. When you run go generate on this file, it will in turn use go tool to execute the version of the stringer tool listed in your go.mod file. Let's try it out: $ go generate . $ ls go.mod go.sum level_string.go main.go Makefile You should see that a new level_string.go file is created, and running the application should result in some output that looks like this: $ go run . Info: This is an informational message that should be wrapped. Listing tools You can check which tools have been added to a module by running go list tool, like so: $ go list tool honnef.co/go/tools/cmd/staticcheck golang.org/x/tools/cmd/stringer golang.org/x/vuln/cmd/govulncheck Verifying tools Because the tools are included in your go.mod file as dependencies, if you want to check that the code for the tools stored in your module cache has not changed you can simply run go mod verify: $ go mod verify all modules verified This will check that the code in your module cache exactly matches the corresponding checksums in your go.sum file. Vendoring tools If you run go mod vendor, the code for tooling dependencies will be included in the vendor folder and the vendor/modules.txt manifest alongside your non-tool dependencies. $ go mod vendor $ tree -L 3 . ├── go.mod ├── go.sum ├── main.go ├── Makefile └── vendor ├── github.com │ ├── BurntSushi │ └── kr ├── golang.org │ └── x ├── honnef.co │ └── go └── modules.txt When tools are vendored in this way, running go tool will execute the corresponding code in the vendor directory. Note that go mod verify does not work on vendored code. Upgrading and downgrading tools To upgrade or downgrade a specific tool to a specific version, you can use the same go get -tool import_path@version command that you did for adding the tool originally. For example: $ go get -tool honnef.co/go/tools/cmd/staticcheck@v0.5.0 To upgrade to the latest version of a specific tool, omit the @version suffix. $ go get -tool honnef.co/go/tools/cmd/staticcheck You can also upgrade all tools to their latest version by running go get tool. Note: tool is a sub-command here, not a flag. $ go get tool If your tool dependencies are vendored, you will need to re-run go mod vendor after any upgrades or downgrades. At the time of writing, I'm not aware of any easy way to specifically list the tools that have upgrades available – if you know of one please let me know! Removing tools To remove the tool completely from your module, use go get -tool with the special version tag @none. $ go get -tool honnef.co/go/tools/cmd/staticcheck@none Again, if you're vendoring, make sure to run go mod vendor after removing a tool. Using a separate modfile for tools A Reddit commenter mentioned the potential for problems if your tools share dependencies with your application code. For example, let's say that your application code depends on golang.org/x/sync version v0.11.0, and is tested and known to work with that version. Then if you add a tool that relies on a newer version of golang.org/x/sync, the version number in your go.mod file will be bumped to the newer version and your application code will use that newer version too. In theory, this shouldn't be a problem so long as all your dependencies and their child dependencies are stable, follow strict semantic versioning, and don't make backwards-incompatible changes without a major version increment. But, of course, the real world is messy and backwards-incompatible changes might happen, which could unexpectedly break your application code. It's worth noting that this issue isn't limited to tool dependencies – the same thing can happen if your application code and a non-tool dependency both rely on the same package. However, including tools in go.mod increases the risk. To reduce this risk, you can use a separate modfile for tool dependencies instead of including them in your main go.mod. You can do this with the -modfile flag, specifying an alternative file such as go.tool.mod, like so: # Initialize a go.tool.mod modfile $ go mod init -modfile=go.tool.mod example.com # Add a tool to the module $ go get -tool -modfile=go.tool.mod golang.org/x/vuln/cmd/govulncheck # Run the tool from the command line $ go tool -modfile=go.tool.mod govulncheck # List all tools added to the module $ go list -modfile=go.tool.mod tool # Verify the integrity of the tool dependencies $ go mod verify -modfile=go.tool.mod # Upgrade or downgrade a tool to a specific version $ go get -tool -modfile=go.tool.mod golang.org/x/vuln/cmd/govulncheck@v1.1.2 # Upgrade all tools to their latest version $ go get -modfile=go.tool.mod tool # Remove a tool from the module $ go get -tool -modfile=go.tool.mod golang.org/x/vuln/cmd/govulncheck@none
- Eleven tips for structuring your Go projectsJan 22, 2025
When working with Go, you have three main building blocks to help organize your code: files, packages and modules. But as Go developers, one of the common challenges we have is knowing how to best combine these building blocks to structure a codebase. In this post, I'll share a mix of mindset tips and practical advice that I hope will help, especially if you're new to the language. Different projects, different structures Aim for effective, not perfect Forget conventions from other languages or frameworks Don't use directories just to organize files Use one of the standard layouts as a skeleton … And then let it evolve If you're unsure, begin with two files Keep related things close Big files aren't necessarily bad Create packages judiciously Look out for warning signs 1. Different projects, different structures I'd like to start by emphasizing that there's no single "right" way to structure a Go codebase. If you're using a specific framework or tool to scaffold your project, then you might be given a fixed directory structure to work with. But outside of that, there are relatively few conventions widely-adopted by the Go community, and the answer to "how should I structure my codebase?" is almost always "it depends". It depends on what you're building, your business needs, your testing approach, your team, your dependencies or tooling, and any internal conventions you choose to follow. Take a look at GitHub, and you'll find thousands of examples of successful Go projects — with quite different structures. For example, mkcert and Kubernetes are both excellent Go projects, but they differ significantly in scale and purpose. And these differences mean that their repository structures also look quite different. A structure that works well for your current project might not be the same as the structures that you've used before or seen elsewhere — and that's perfectly fine. 2. Aim for effective, not perfect If you're a perfectionist, this might be easier said than done, but try not to stress too much about making your codebase structure perfect. If you find yourself obsessing over the "perfect" way to organize your code, try to let go of that. Instead, aim for a structure that works effectively enough for your specific project. By "effective enough," I mean that your code is easy to find and navigate, the logic is straightforward to follow, changes can be made with confidence, and you're not running into the kind of warning signs I'll cover later in this post. 3. Forget conventions from other languages or frameworks Don't feel guilty if your codebase structure doesn't follow the conventions or best practices you're used to from other languages or frameworks. If it works effectively for your Go project, that's what matters. For example, if you're an experienced Ruby on Rails or Django developer building your first Go web application, you might be tempted to recreate the familiar directory structure from those frameworks. But while you probably could make it work if you tried, it's unlikely to be the most effective or simple solution for your Go project. 4. Don't use directories just to organize files This is a subtle but important point, especially if you're new to Go. You shouldn't create new directories just to organize your .go files. In Go, creating a directory creates a new package, and placing a file in that directory makes it part of that package. Create a directory only when you have a specific reason to create a new package – not because you want a neater/cleaner/clearer directory structure for your files. 5. Use one of the standard layouts as a skeleton The official Go documentation has a great article describing some standard project layouts. I use one of these layouts as the high level "skeleton structure" in pretty much every Go project I work on nowadays, and recommend that you do too. Small projects For small projects, consider using the basic layout where you just put everything in the project's root directory, like this: ├── main.go ├── foo.go ├── bar.go ├── go.mod └── README.md A couple of real-life examples of projects that use this layout are mkcert and flow. Small projects with supporting packages For projects where you need to break out some code out into supporting packages, use the supporting packages layout. In this pattern, the supporting packages live within an internal directory in the project root, and your main package files and other project assets continue to live in the root directory. ├── internal │ └── foo │ └── foo.go ├── main.go ├── bar.go ├── go.mod └── README.md Note: The directory name internal carries a special meaning and behavior in Go: any package which lives under this directory can only be imported by code inside the parent of the internal directory. So if you put your supporting packages in an internal directory, like above, they cannot be imported by code outside of your project – even if the project code is publicly available somewhere like GitHub. That's often useful, because it means you can confidently refactor the code within the internal folder without inadvertently breaking something for other people. Larger projects For larger projects I generally recommend using the server project layout, especially if: Your project will have a lot non .go assets (like template files, SQL migrations, tool configurations and Makefiles); or Your project will contain more than one main package (e.g. main packages for a web application and a CLI tool) In this layout: Your executable main package files live in sub-directories under a cmd directory The rest of your Go packages live in an internal directory All other project assets remain in the root of the project directory Like so: ├── cmd │ └── foo │ ├── main.go │ └── bar.go ├── internal │ └── baz │ └── baz.go ├── go.mod ├── Makefile └── README.md For a more complete example, here's the directory structure from a recent project I worked on – including main packages for a web server and CLI application, along with various non-Go assets. 6. … And then let it evolve Use one of the standard project layouts as your high-level 'skeleton', but beyond that, I recommend letting the rest of the structure within that skeleton evolve naturally as development progresses. In other words, don't decide your directory structure or what .go files you will have upfront, and then shoehorn in your Go code into that. Instead, let the code you're writing guide the files and packages that you create. 7. If you're unsure, begin with two files If you're in any doubt, start with the basic layout and just a go.mod and main.go file in the root of your project directory. Then, as your project evolves, add additional files and packages as needed. Starting this way is perfectly OK. Personally, about half of the new projects I work on begin with just these two files — and nothing more. 8. Keep related things close This one feels pretty obvious – especially if you're an experienced developer – but it's still worth saying. As a general rule, keep related things close to each other – in the same .go file or in the same package. Here are a few examples: Constants, variables, custom types and utility functions (which are not reused by multiple packages) should be declared close to the code they support, in the same .go file or package. It may make sense to group utility functions that are related to each other and used in multiple places into a single reusable package. If you have a custom struct type, define any methods for it directly below the struct declaration in the same .go file. In a web application or API, define all routing rules together in a single function or .go file. There will probably be times when it makes sense for you to break the 'keep related things close' rule in your code, and that's OK, but it's a good principle to default to. 9. Big files aren't necessarily bad So long as it doesn't cause you practical problems during development or maintenance, file size in Go doesn't matter. It's OK to have .go files that contain a couple of lines, or thousands. Neither of these things is automatically considered an anti-pattern in Go. To give you an idea of some big files, the runtime/proc.go file from the Go standard library contains 6,548 lines of code. And /pkg/apis/core/validation/validation.go from the Kubernetes repository contains 8,606 lines of code (it's corresponding _test.go file also has over 26,000 lines). I'm not saying that your .go files should be big. More that if – on balance – it makes sense to have a big file… then it makes sense. Don't feel guilty about it, and don't feel like you need to break it into smaller files unless there's a good reason to. 10. Create packages judiciously In a similar vein, big packages aren't necessarily bad. In fact, I'd say that one of the more common mistakes in Go is splitting up your code into too many small packages. The problem with having lots of small packages is that it can add complexity to your application, especially when you need to share state, configuration, or dependencies across package boundaries. It also increases the likelihood of encountering import cycle problems. As a rule of thumb, only create additional packages when you have a demonstrable need or good reason to. For example: You have some code that you want to reuse. Putting the code in a standalone package facilitates this because you can then import the package and use it in different files throughout your project, or even copy-and-paste the package directory straight into another codebase. You want to isolate or enforce a boundary between the package code and the rest of your project. For example, you might use packages as an architectural tool to create lightweight decoupled 'layers' in your project code, or to isolate part of the codebase so it's easier for another person or team to work on separately. You have some code that acts as a 'black box' and moving it to a standalone package will reduce cognitive overhead and make your codebase clearer overall. 11. Look out for warning signs It can be hard to know exactly when your project structure is working effectively… instead it's probably easier to spot the signs that it isn't working effectively in practice. Some things to look out for are: You keep running into import cycle problems. It's hard to find things in the codebase, especially after time away or for new contributors. Relatively small changes often impact multiple packages or .go files. The flow of control is overly "jumpy" and hard to follow when debugging. There's a lot of duplication that's difficult to refactor out (note: some duplication is not always bad.) You're finding it difficult to manage errors appropriately. You feel like your are 'fighting the language', or you resort to using language features in a way that is not intended or idiomatic. It feels like a single file or package is doing too much and that there isn't a clear separation of responsibilities within it, and this is having a negative effect on the clarity of your code. If you spot these warning signs, it might be worth taking a step back and considering if tweaking the structure your codebase and packages will help to fix the problem.
- Implementing an in-memory cache in GoDec 22, 2023
In almost all web applications that I build, I end up needing to persist some data – either for a short period of time (such as caching the result of an expensive database query), or for the lifetime of the running application until it is restarted. When your application is a single binary running on a single machine, a simple, effective, and no-dependency way to do this is by persisting the data in memory using a mutex-protected map. And since generics was introduced in Go 1.18, it's possible to write a generic implementation that you can use to persist various different data types in a type-safe way. Note: The code for this post can be found in this gist. Long-lived cache If you want to persist data for the lifetime of the running application (or until you deliberately delete the data), you can create a generic Cache type like this: package cache import ( "sync" "time" ) // Cache is a basic in-memory key-value cache implementation. type Cache[K comparable, V any] struct { items map[K]V // The map storing key-value pairs. mu sync.Mutex // Mutex for controlling concurrent access to the cache. } // New creates a new Cache instance. func New[K comparable, V any]() *Cache[K, V] { return &Cache[K, V]{ items: make(map[K]V), } } // Set adds or updates a key-value pair in the cache. func (c *Cache[K, V]) Set(key K, value V) { c.mu.Lock() defer c.mu.Unlock() c.items[key] = value } // Get retrieves the value associated with the given key from the cache. The bool // return value will be false if no matching key is found, and true otherwise. func (c *Cache[K, V]) Get(key K) (V, bool) { c.mu.Lock() defer c.mu.Unlock() value, found := c.items[key] return value, found } // Remove deletes the key-value pair with the specified key from the cache. func (c *Cache[K, V]) Remove(key K) { c.mu.Lock() defer c.mu.Unlock() delete(c.items, key) } // Pop removes and returns the value associated with the specified key from the cache. func (c *Cache[K, V]) Pop(key K) (V, bool) { c.mu.Lock() defer c.mu.Unlock() value, found := c.items[key] // If the key is found, delete the key-value pair from the cache. if found { delete(c.items, key) } return value, found } And you can use it like this: package main import ( "fmt" "time" "path/to/cache" ) func main() { // Create a new Cache instance myCache := cache.New[string, int]() // Set key-value pairs in the cache myCache.Set("one", 1) myCache.Set("two", 2) myCache.Set("three", 3) // Retrieve values from the cache value, found := myCache.Get("two") if found { fmt.Printf("Value for key 'two': %v\n", value) } else { fmt.Println("Key 'two' not found in the cache") } // Pop a key from the cache poppedValue, found := myCache.Pop("three") if found { fmt.Printf("Popped value for key 'three': %v\n", poppedValue) } else { fmt.Println("Key 'three' not found in the cache") } // Remove a key from the cache myCache.Remove("one") // Try to retrieve a removed key removedValue, found := myCache.Get("one") if found { fmt.Printf("Value for key 'one': %v\n", removedValue) } else { fmt.Println("Key 'one' not found in the cache (after removal)") } } Expiring cache You can extend this idea to associate an expiry time with every value in the cache, and launch a background goroutine to periodically remove expired entries. Like so: package cache import ( "sync" "time" ) // item represents a cache item with a value and an expiration time. type item[V any] struct { value V expiry time.Time } // isExpired checks if the cache item has expired. func (i item[V]) isExpired() bool { return time.Now().After(i.expiry) } // TTLCache is a generic cache implementation with support for time-to-live // (TTL) expiration. type TTLCache[K comparable, V any] struct { items map[K]item[V] // The map storing cache items. mu sync.Mutex // Mutex for controlling concurrent access to the cache. } // NewTTL creates a new TTLCache instance and starts a goroutine to periodically // remove expired items every 5 seconds. func NewTTL[K comparable, V any]() *TTLCache[K, V] { c := &TTLCache[K, V]{ items: make(map[K]item[V]), } go func() { for range time.Tick(5 * time.Second) { c.mu.Lock() // Iterate over the cache items and delete expired ones. for key, item := range c.items { if item.isExpired() { delete(c.items, key) } } c.mu.Unlock() } }() return c } // Set adds a new item to the cache with the specified key, value, and // time-to-live (TTL). func (c *TTLCache[K, V]) Set(key K, value V, ttl time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.items[key] = item[V]{ value: value, expiry: time.Now().Add(ttl), } } // Get retrieves the value associated with the given key from the cache. func (c *TTLCache[K, V]) Get(key K) (V, bool) { c.mu.Lock() defer c.mu.Unlock() item, found := c.items[key] if !found { // If the key is not found, return the zero value for V and false. return item.value, false } if item.isExpired() { // If the item has expired, remove it from the cache and return the // value and false. delete(c.items, key) return item.value, false } // Otherwise return the value and true. return item.value, true } // Remove removes the item with the specified key from the cache. func (c *TTLCache[K, V]) Remove(key K) { c.mu.Lock() defer c.mu.Unlock() // Delete the item with the given key from the cache. delete(c.items, key) } // Pop removes and returns the item with the specified key from the cache. func (c *TTLCache[K, V]) Pop(key K) (V, bool) { c.mu.Lock() defer c.mu.Unlock() item, found := c.items[key] if !found { // If the key is not found, return the zero value for V and false. return item.value, false } // If the key is found, delete the item from the cache. delete(c.items, key) if item.isExpired() { // If the item has expired, return the value and false. return item.value, false } // Otherwise return the value and true. return item.value, true } And you can use this in much the same way: package main import ( "fmt" "time" "path/to/cache" ) func main() { // Create a new TTLCache instance myTTLCache := cache.NewTTL[string, int]() // Set key-value pairs with TTL in the cache myTTLCache.Set("one", 1, 5*time.Second) myTTLCache.Set("two", 2, 10*time.Second) myTTLCache.Set("three", 3, 15*time.Second) // Retrieve values from the cache value, found := myTTLCache.Get("two") if found { fmt.Printf("Value for key 'two': %v\n", value) } else { fmt.Println("Key 'two' not found in the cache or has expired") } // Wait for a while to allow some items to expire time.Sleep(7 * time.Second) // Try to retrieve an expired key expiredValue, found := myTTLCache.Get("one") if found { fmt.Printf("Value for key 'one': %v\n", expiredValue) } else { fmt.Println("Key 'one' not found in the cache or has expired") } // Pop a key from the cache poppedValue, found := myTTLCache.Pop("two") if found { fmt.Printf("Popped value for key 'two': %v\n", poppedValue) } else { fmt.Println("Key 'two' not found in the cache or has expired") } // Remove a key from the cache myTTLCache.Remove("three") }
Bob Nystrom
- The Value of ThingsJan 24, 2026
One of the reasons I write is to help me organize my own mind. I have a compulsive need to figure things out and I’ll lay awake at night shuffling sentences around in my head until it hangs together. Then I just have to try to get it all down in Markdown before it dissolves back into chaos. Like a lot of people these days, I am losing a lot of sleep over LLMs and generative AI. I mean that literally. I’ve had nights where I tossed and turned worrying about whether AI is going to destroy a career I love or wreck modern society until the sun comes up. For brevity’s sake, I’ll use “AI” here mostly to mean “generative AI”. The stuff that LLMs and products like ChatGPT do. I think it’s useful to distinguish that from other uses of machine learning like categorization and labeling. But here, all I care about is the generative stuff. I keep hoping I’ll sort it out in my head enough to reach some inner peace. I’m not saying I will literally figure it out, like, for the world. But I keep hoping I can at least figure out my own relationship with AI. I’m still not there, but I was able to pull one thread out of the tangled discourse around AI and get it to make sense in my head. I hope it will help you too. Or maybe this is all nonsense and you’ll point out the myriad ways in which I’m wrong. That’s a possibility, but I’m trying to be more courageous in my writing, so here I am. Anyway. Generative AI is about using machine learning to produce digital things. For AI to be a net good for the world, at the very least, the things it produces must have value. But what does it mean for a thing to have “value”? I say “at the very least” here because even if AI produces valuable things, it can still be a net bad. Societal effects and other externalities matter! But I haven’t untangled any of that stuff in my head yet, so I’ll ignore that here. Utility#utility The obvious answer is that a thing has value if it’s useful. If it solves some material problem in the world or at least creates some joy, that’s value. In the UX world, they call this “utility”: the thing a thing actually does. In UX, “utility” is used to distinguish from “usability”—how easy it is to do the thing. An apple has utility. You can eat it and it will give you some calories, some hydration, a little fiber. If it’s not an oxymoronically named Red Delicious, it might even bring you some delight. Material objects have a natural sort of utility from their physicality, but digital objects can have utility too. A while back, I took an interest in audio programming. I read a lot of articles online to learn some of the math and algorithms behind digital signal processing. That information is useful. Before I read those articles, I couldn’t program an FM synth. Now I can. That utility is just as real whether I got it from reading a classic article written by Julius O. Smith III, or from asking ChatGPT. There is a whole separate question about whether generative AI even can reliably produce good information. For this article, I’m presuming a steelman AI. Presume that we have the best version of generative AI that does do what it’s supposed to. Even then, how should we think about using it? In both the physical and digital worlds, utility really does matter. One possible anthropological definition of “technology” is anything that enables humans to more efficiently generate utility. It’s central to human progress. The reason I can go to a store and buy a bag of apples is because thousands of years of agriculture technology development means humans can very efficiently produce and transport food. And, you know, we need food to live. Food is cool. My career as a programming language designer is only possible because of all of the material I found online to learn how compilers and languages work. Hobbies like knitting, making music, and cooking are deeply important to me. They’re possible in large part from learning through YouTube videos. Those articles and videos were truly useful to me. Generative AI can help improve this. Here’s a concrete example: I got curious about how AI is affecting software jobs outside of my weird tech company bubble so I started poking around job listing sites. I stumbled onto a software engineer position working for the Washington Department of Ecology. The listing says: We’re looking for two journey-level developers who care about clean architecture, thrive in Agile teams, and see modern tooling—including AI-assisted development—as a way to work smarter, not riskier. You’ll collaborate with product, architecture, security, and platform engineering partners to design secure, accessible, scalable applications that replace decades of legacy complexity. … Use AI-Assisted Tools Responsibly and Effectively Leverage AI tools for boilerplate, test generation, and safe refactoring. Validate AI-generated code for accuracy, security, and maintainability — no blind trust, no shortcuts. Share best practices with teammates adopting new AI workflows. It’s hard for me to imagine a better morally justified use of AI for software development. This is a government office whose charter is to “protect and sustain healthy land, air, water, and climate in harmony with a strong economy”. They are literally working to make the world better for nature and humans. But as a government department, they are also really limited in their resources. If AI can make those two software engineers more productive, it can take the same tax dollars as input and produce a healthier world as output. That’s hard to argue against. Meaning#meaning When people get excited about AI and productivity, I think utility is what they have in mind. But utility is not the only kind of value a thing can have. A couple of years ago, I hand-knitted a scarf for my mother-in-law. The scarf does have utility. It’s a rectangle of fabric that keeps her neck warm. The Olympic Peninsula is a lovely corner of the country, but it’s often a chilly one. A scarf made out of wet tissue paper would not have the same utility. I could have just gone to the store and bought her a scarf. Were I the kind of robotic rational actor who thinks only about maximizing their utility function when not sipping pour-overs in some culty rationalist commune outside of SF, then I would know that the optimal strategy is to use my software engineer skills to maximally turn my labor into cash and then use that cash to buy a scarf. The scarf I hand-made took dozens of hours to make. At even a modest West Coast software engineer salary, that number of hours would buy an extremely nice scarf. Maybe not vicuña, but at least cashmere. Had I dropped a few hundred bucks on a scarf, would it have more value? Realistically, it would be a better scarf. Softer material, finer stitches. Certainly more fashionable. Fashion is social signalling. Social signals have utility and value too. You and I know the answer is “no”. As any parent who lovingly clips their kid’s objectively terrible art to the fridge knows, the value of a made thing is not entirely based on its merits. That scarf has value not because it’s a great scarf but because I chose to spend an irreplaceable fraction of my life making it. It’s a symbol of how much I care about her. In short, it has meaning. Meaning doesn’t make an object more useful. It’s not even transferrable. If my mother-in-law gave the scarf to you, its meaning would evaporate. But I bet that if you think about the objects that have the most value to you, your S-tier will be populated by things that excel mostly in meaning, not utility. The things that you would grab before running out of a burning house are the things with sentimental, not functional value. Source of meaning#source-of-meaning You kind of know the answer intuitively, but I’m trying to be clear in my thinking, so when I talk about objects having meaning… where does that meaning come from? Why do some objects have more meaning than others? I’ve spent hundreds of hours knitting over the past couple of years, mostly making things for friends and family. I’ve thought about this question a lot. I suspect that it comes down to spending time in service of another. Humans are sentient actors with agency and entire cognitive universes in our heads. But we are also animals made of meat and bones, and that flesh doesn’t last forever. Every one of us has a finite amount of things we will ever do. “Spend time” is one of those stock phrases that has been sucked dry of semantic flavor over the years, but there is a profound metaphor there. When you choose to use some of your time making a thing for someone, you don’t get that time back. It is spent. Choosing to spend a fraction of our most precious resource for someone else is the strongest signal we can send that that person matters to us. If we want to put our philospher hat on and play five whys, we might wonder why someone spending time on us is a thing we value. Why does someone burning their life’s candle warm our heart? I believe it’s because we’ve evolved to be a social species and we’re hard-wired to feel good when we think the tribe wants and values us. I’m in my late 40s. I hope I still have a lot of living left to do, but the odds are very good that I’m past the mid-point of my lifespan. An increasing fraction of conversations with my friends revolve around our various medical tribulations. I can feel the finiteness of life in my bones, quite literally. When I think about what I want to do with my remaining allotment of life, the answer that resonates is taking care of and making things for the people I love. Post-traumatic osteoarthritis sucks. Generating meaning#generating-meaning Generative AI, when wielded deftly, can be an amazing tool for creating things with utility faster and more easily than you ever could before. But it can’t generate meaning. The giant matrix of floating point numbers in a rack of GPUs in some data center does not love you. Another story: When my brother and I were growing up, we were really into movies. We made short videos (hilariously bad), learned how to do special effects make-up (actually tolerably good), and all sorts of stuff like that. We dreamed about growing up and becoming another pair of Hollywood brothers like the Zuckers or Coens. Many years later, as a birthday present, I wrote my brother a screenplay for a short horror film about a mythological siren. I toiled on it every night after the kids went to bed for weeks. It’s one of my favorite gifts. I don’t know if we’ll ever get a chance to shoot it. We live on opposite sides of the country and he can’t handle the gloom of Seattle any more than I can handle the politics of the South. It’s likely this screenplay has zero utility. But it still has a ton of meaning because I sweated every single word in that stack of 12-point Courier pages. Today, with the help of ChatGPT, I could probably put together a feature-length screenplay in a tenth of the time. It might even be an objectively better screenplay for a better movie. But because I made the screenplay in a tenth of the time thanks to ChatGPT’s help, it would hold only a tenth of the meaning for my brother. If my hypothesis that meaning comes from time sacrifice is true, then by making us more productive, AI eliminates meaning. I mean, if ChatGPT is good enough for the Duffer brothers—another great pair of filmaking bros—who am I to judge? Both kinds of value#both-kinds-of-value Most objects, digital or material, have a mixture of both kinds of meaning. My favorite gifts to give or receive are those rare ones that are both thoughtful and useful. Many years ago, I had a friend over for dinner. The next time I saw him, he handed me a rice cooker. It wasn’t a particularly expensive one or anything, but he saw the direction my cooking hobby was going and knew I would get a lot of use out of it. We’ve used that thing a hundred times since. An object carries with it the weight of time that was put into making it and has a certain heft from what you can do with it. I think of AI as a tool that can transmute that former kind of value into the latter. It drains some of the meaning out, but in return lets you make more things with utility for the same effort. There is nothing unique to AI about this. Any tool that increases efficiency has the same property. They make little knitting machines that make rows of stitches as easily as turning a crank. I could have made a scarf for my mother-in-law using that in a fraction of the time. Efficiency—the multiplier that determines how much effort is required to make an object—works sort of like a slider that lets us choose the ratio of utility and meaning the resulting thing has. Generative AI, assuming the claims are true, has the capability to radically move that slider. It can produce huge volumes of stuff with real utility. But that exact same ease implies that the results are all devoid of a sense of personal meaning. Two tangents#two-tangents I don’t want to take the idea that efficiency is a slider between meaningful-but-scarce and meaningless-plenty too far. It’s a rough model. There are at least two problems with it: First, it suggests that you can make an object infinitely meaningful by just making the process arbitrarily hard. I could have knitted that scarf using two leftover take-out Chinese bamboo chopsticks. In the dark. One-handed. That would certainly have required a greater sacrifice of time (and sanity) on my part. But there’s obviously a point of diminishing returns where people value the effort we put into making things but less so when we elect to be masochists. Also, when making things, we often derive personal joy from the process itself. Yes, I did sacrifice time to knit a scarf but… I like knitting. It wasn’t an entirely miserable toil or something. I got to play with yarn. The high level point is just that the more we automate the process of making a thing, the less of ourselves we put into it. And an object with less of ourselves in it is often valued less by the person who receives it. That’s all I’m saying. What kind of value do you want?#what-kind-of-value-do-you-want The tools we use to make things and the resulting productivity gives us a lever to control how much we prioritize getting a useful object out fast versus getting an object suffused with meaning. That suggests a way to decide when we should and shouldn’t use AI. If you just need a thing that does a thing, then its utility is what matters. I want the Washington Department of Ecology to use my very limited tax dollars as effectively as they can to keep the Pacific Northwest the enchanting natural marvel that it is. If they can do that better using AI, then by all means they should do so. If you want to make a thing that has some emotional resonance or some connection to other people—whether that be a loved one, an audience, or humanity as a whole—then you don’t want to suck out any of its meaning. I listen to a lot of electronic music while I work. In some sense, it is “utility music”. I just want a vibe to help me tune out the world and focus. I can’t even handle lyrics while I’m programming or writing. Erik Satie, the OG of ambient, described his compositions as “furniture music”. He described it in explicitly utilitarian terms: I think of it as melodious, softening the noises of the knives and forks at dinner, not dominating them, not imposing itself. It would fill up those heavy silences that sometime fall between friends dining together. It would spare them the trouble of paying attention to their own banal remarks. And at the same time it would neutralize the street noises which so indiscreetly enter into the play of conversation. To make such music would be to respond to a need. I have to admit that the genres I listen to are the ones most amenable to being automatically generated by machines. I literally do listen to generative ambient which has been a little corner of the music world since well before “generative” glommed onto the word “AI”. Even so, I still find my listening experience more gratifying when the music was made by someone who actually gave a shit about it. Even though the relationship between artist and listener is vague and indirect, that connection matters to me. I know that when I make music, the thing that helps me push through all of the many frustrations of producing is the hope—however tentative—that at the end of it all, one day someone might put on a track of mine and find their day improved by it being the temporary soundtrack to their life. Those moments of connection between artist and audience are what I live for as a creative person. For those, I can’t see generative AI as anything but harmful. Sometimes yes and sometimes no#sometimes-yes-and-sometimes-no Thinking about the things I make and consume and the kinds of value they contain helps me sort out some of my extremely conflicted feelings around AI. I want to emphasize that I’m only looking at this from the perspective of a single person’s individual use of AI. The global effects of AI are just as if not more important. But I haven’t sorted out my feelings around that to have any idea what to say here. Maybe I never will. But I can set that aside and imagine a world where I can run a generative AI completely locally on my machine. A world where I was the only person on Earth who had an AI. In that timeline, there would be almost no externalities to consider. Even then, I still want to be mindful of the consequences of using AI to help me make. In my own uses, I aim to focus on the places where I think AI can improve the efficiency of making utilitarian goods. And when I’m making something where the human connection and meaning are important, I will try to put as much of myself and as little of the machine into it as I can.
- Setting Up an SDL3 Mac App in XCode 16Jul 13, 2025
This is mainly a note for my future self, but making it a blog post in case it helps anyone else. This weekend, I decided to try making a little SDL3 app on my new M4 MacBook Pro. It took me a few hours to figure out how to get XCode to play nice with the SDL3 framework given MacOS’s paranoia around unsigned code and malware. Here’s the summary for anyone whose Google searching takes them here. The instructions are fairly similar to the SDL3 README for MacOS: Start up XCode. Choose “File > New > Project…”. Select “macOS”, then under “Application” choose “App”. This step is important. Choosing “Command Line Tool” will not work. Click “Next”. Give your app a name. For “Interface”, choose “XIB”. For “Language”, choose “Objective-C”. Click “Next”. Choose a place to save your project. Now you have a vanilla “Hello World” Mac app. The next step is integrating SDL: Download a release of SDL. As I’m writing this, the latest is 3.2.16. Download the corresponding .dmg file. Open it in Finder. Open SDL3.xcframework/macos-arm64_x86_64 and copy SDL3.framework from there to somewhere in your XCode project’s source tree. (I made a lib folder and put it there in mine.) In XCode, in the file explorer on the left, click the top project icon to open the settings for it. In the main panel on the right, click the icon for your app target under “Targets” to open the target settings. Under “Frameworks, Libraries, and Embedded Content”, click the “+” to add a new framework. In the popup that appears, click “Add Other… > Add Files…”. Navigate to wherever you copied “SDL3.framework” and choose it. Then let’s use SDL and verify everything is working. In main.m, comment out the entire main() function. Make a new main.c file and copy the contents of hello.c from the SDL docs into it. Compile and run. If that all works, you should see a delightful pixelated “Hello World!” fill your screen. Press any key to quit. Let’s make sure the full iteration loop works. (Read below to see why I feel the need to verify this.): In main.c inside SDL_AppIterate(), change the message string. Hit Command-R to run. You should see your updated message on screen. There you go, you now have a working shell of a Mac app with using a local install of SDL3. You can probably delete the main.m file entirely and maybe some of the other generated files that XCode put in the new project, but I haven’t figured that out yet. Now here’s all the wrong stuff I tried first before I got that working… Attempt 1: Download a release and use the xcframework#attempt-1-download-a-release-and-use-the-xcframework In XCode, I created a new “CLI Command” C app. That got me a “Hello, world!” up and running without any problems. Then I copied the contents of this SDL hello example into my source file. As expected, that failed to compile because it couldn’t find SDL3/SDL.h. So next is getting SDL linked in. The first thing I did was download the 3.2.16 stable release installer. I downloaded the DMG file and opened that. Then following the instructions, I dragged the entire SDL3.xcframework directory into the XCode project. That correctly added a reference to the Framework and got XCode to a point where it could resolve the include and compile the program. Great. But when I ran it, it failed at startup. The OS tried to dynamically load the SDL3 framework, but then balked because it thinks the framework isn’t correctly signed and safe to use. The OS gave me a popup error with “SDL3.framework Not Opened” because “macOS cannot verify that this app is free from malware”. Crap. Attempt 2: Embed the framework#attempt-2-embed-the-framework Poking around online, some people say this error can be fixed by embedding the SDL framework in my app and having XCode sign it when it embeds it. In XCode, in the target settings under “Build Phases”, there is a panel for “Embed Frameworks”. I dragged SDL3.xcframework under there and checked “Code Sign On Copy”. Alas, when I tried to compile, now I got a build error. It complained that two tasks in the build were trying to produce the same file. I’ve lost the exact error message but it looked like one of the tasks was a simple file copy and the other was a “ProcessXCFramework” invocation. I’m guessing the latter is the task that signs and embeds the framework. I tried basically every combination of build settings to try to fix this: removing the framework from “Link Binary with Libraries”, checking “Copy only when installing”, and a bunch of other things I don’t remember. Nothing worked. Either I removed so much that the compiler couldn’t find the framework at all, or it got the duplicate build file error. Attempt 3: Don’t use the entire “SDL3.xcframework”#attempt-3-dont-use-the-entire-sdl3xcframework I started watching this video to see if it could help. He doesn’t use the entire SDL3.xcframework bundle. Instead, he opens that, goes into macos-arm64_x86_64 and copies the SDL3.framework framework out of there. I removed SDL3.xcframework from my XCode project and dragged SDL3.framework into it instead. That gave me something that compiled, but when I tried to run, it failed at load time with: dyld[15605]: Library not loaded: @rpath/SDL3.framework/Versions/A/SDL3 Referenced from: /Users/... Reason: tried: '/Users/.../Build/Products/Debug/SDL3.framework/ Versions/A/SDL3' (no such file) In the target settings, I again added the framework (but this time SDL3.framework not SDL3.xcframework to “Embed frameworks”). For reasons that are entirely unclear to me, that worked. I hit Command-R and… Hallelujah. All done, right? Not so fast. I made a tiny tweak to the code and tried to run it again. The compile immediately failed with: 'SDL3/SDL.h' file not found What? I literally just compiled and ran it. Just to make sure I wasn’t crazy, I cleaned the build directory and compiled again. That worked! “Hello World!” popped up on screen again. Build again… same error. Clean build and then build. Works! So I can build once but then something in the build directly causes any subsequent compile to fail. The full error is: Did not find header 'SDL.h' in framework 'SDL3' (loaded from '/Users/.../Index.noindex/Build/Products/Debug') So what I think is happening is that in the first clean build, it locates the framework inside my app’s main directory. That framework does have the headers so the compiler can find what it needs. Then after that first build, the compiler starts using the framework that it copied into the build directory. But when I look in there, Debug is completely empty. Attempt 4: Project-relative DerivedData#attempt-4-project-relative-deriveddata I hate when tools stuff built data in random system directories unrelated to the project I’m working on because those files end up sitting around forever. So the next thing I did was go under “Project Settings” and changed the “Derived Data” to be “Project-relative Location”. I cleaned and built successfully. But, again, if I made a trivial code change and tried to build again, it failed. This time, though, there was actually something in DerivedData/my_app/Build/Products/Debug/. I can see an SDL3.framework directory under there. It contains Versions/A/SDL3 (and some other stuff). So the library is there as expected. But there is no Headers directory under Versions/A/, nor is there a Headers symlink in SDL3.framework pointing to it. In theory, that’s fine. In the compiled app, all that’s needed is the library itself. But for reasons I don’t understand, XCode is trying to use this semi-copied version of the framework for subsequent compiles instead of the original one in my project’s source tree. Attempt 5: Explicit header search path#attempt-5-explicit-header-search-path OK, if XCode can’t find the headers because it’s looking in the wrong place, maybe I can just tell it where to look. Under “Search Paths”, I added lib/SDL3.framework/Headers to “Header Search Paths”. (I put the framework under lib in my project tree, hence the lib/ part.) Didn’t help. Tried making it recursive. Nope. Maybe just lib/SDL3.framework? No. I’m guessing the problem here is that the include paths look like SDL3/SDL.h, not just SDL.h, so I need to get XCode to understand that the headers are inside a framework to resolve the SDL3/ part. Attempt 6: Framework search path#attempt-6-framework-search-path OK, so maybe it’s not a header search path. Instead, I tried adding lib/ to “Framework Search Paths”. No help. Making it recursive didn’t help. Neither did lib/SDL3.framework. I do a bunch of Googling and eventually stumble onto this old StackOverflow post. He specifically mentions only getting the error after the first build, so this is my exact issue. And the author apparently found a solution! …which they described only as “It works know after certain changes!”. So helpful. I eventually found this Apple Developer forum thread, which also sounds like my problem. Attempt 7: An App instead of a CLI Command#attempt-7-an-app-instead-of-a-cli-command The forum thread suggests that the issue may have something to do with creating a “CLI Command” instead of an “App”. I’ve always done the former in XCode because that seemed like the simplest way to get a vanilla C/C++ app up and running. In older versions of XCode/macOS, it worked fine. But maybe it doesn’t play nice with frameworks in later versions? I trash the entire XCode project and create a new one. This time I choose “App” instead of “CLI Command”. I set the language to “Objective-C” (because that’s the “closest” to C, I guess—I’m winging it here). I set “Interface” to “XIB” based on this README. I run it and get an empty window, the “Hello World” of GUI apps. OK. I copy SDL3.framework out of SDL3.xcframework/macos-arm64_x86-x64 into lib/ in my new app’s source tree. Then in XCode, in the “General” tab of the main target, under “Frameworks, Libraries, and Embedded Content”, I click the “+”. From there I choose “Add Other…”, “Add Files…”, and add lib/SDL3.framework. I admit to feeling a moment of anticipation. Will this actually work? I make a new .c file and paste in the hello.c SDL3 example code. I comment out main() in the main.m that XCode generated. Compile and… it runs! I’ve got an SDL3 “Hello World!” on screen again. Now the real test… can I make a change and build again? Yes! It works! Finally! As far as I can tell, I am up and running now. I didn’t have to add SDL3 to my system framework path. (I didn’t want to do that because ultimately, I want an app that users can just run without having to install SDL themselves.) So it looks like the failure mode was creating a “CLI Command” instead of an “App”. How on Earth would I be expected to know that?
- Consider KnittingMay 30, 2025
Let’s say that, like me, you are a person who stares at a computer and writes code for a living. As a straight male who grew up in a time where knitting was very strongly female coded, it for the most part never occurred to me that knitting was a thing I could do and might enjoy. Regardless of your demographic categories and background, it’s possible that you have also not really considered knitting. In retrospect, this is pretty dumb. I did learn to cross-stitch when I was a kid and spent many hours doing that. I was mostly raised by my mom, so I learned a lot of crafty stuff. I also tried to learn how to crochet from my Grandmother a few times. But some combination of her being a mean old lady and me being left-handed made that not really work out. I made a friend in my twenties who was and is an avid knitter. I certainly admired her skills, but didn’t think to ask her to teach me. This article exists to get you to do so. Specifically, I’ll try to convince you, one software person to another, why it might be a good fit for your life and brain. This is a pitch for knitting, but—for better or worse—an extremely nerdily argued one. Frog and Toad, from this delightful pattern. I made these as Christmas presents for my daughters. Before I start, note that when I say “knitting”, you can read that as any of the various fiber arts, including crochet, weaving, macramé, cross-stitch, etc. I talk about knitting here because that’s the one closest to my heart and I strive to speak from the heart. You can make stuff out of string however you want. We are all fiber friends. The sense of touch#the-sense-of-touch I love the aesthetics of programming. Sitting in a cool quiet room, techno thumping in my headphones, coffee mug next to me, while a neatly arranged field of glowing monospace glyphs stream across my screen. But there’s one sense unmentioned in that sentence: touch. The sense we devote more neurons to than any other is curiously underutilized while pumping out code. It’s no surprise that some programmers fetishize keyboards. It’s just about the only part of programming that has any physical sensation at all. I got into knitting a few years after the pandemic. While I have a variety of hobbies, most still involve staring at a screen and maybe pushing some buttons and turning knobs. When I wasn’t doing those, I was staring at a screen for work, or staring at a screen for not-work. I don’t know if I have a good way to explain how much my body craved tactile experience by the end of that. It’s like my fingers ached. A deep hunger, but not for taste. I’d wander around the house, driving my wife insane, unable to sit down and get comfortable. My body was just screaming at me to do something. My youngest daughter had just picked up knitting, and she taught me. At first it was frustrating and annoying. But once I had the basics down, it was like a a deep sigh felt in my hands. Knitting is so touch-centered. Skilled knitters can knit without looking at their hands at all—touch alone is sufficient. Starting to work on a slanting stripes scarf for myself. I wish you could reach into this photo and feel how unbelievably soft and squishy this scarf is. Wearing it feels like giving myself a warm gentle hug. If I ever become a billionaire, I’m going to build an indoor swimming pool and fill it with Malabrigo Rios yarn. There are so many different kinds of yarn to work with and they all feel different. Cotton is tough and firm, like twine. Wool is soft and springy, forgiving as you pull stitches open to work them. Superwash wool is smooth and glides off the needles. Non-superwash wool has this very slight stickiness to it that makes the resulting fabric feel solid and whole. Thin fingering weight yarn wraps tightly around your finger like a reminder knot. Working it is like performing delicate surgery. Squishy chunky wool spreads your fingers wide and works so quickly it’s like fabric is spooling out of your hands on its own accord. The wool for this hat is a mixture of alpaca and sheep's wool. It's soft, fuzzy, and just the tiniest bit scratchy. Even needles each have their own personality. Stiff grippy bamboo with its dull clack. Less worrisome to use because stitches don’t slide off as easily, but harder work to push against the friction. Polished stainless steel where the stitches fly off the needles—good when you are done with those stitches but not so much otherwise. An inauspicious start for our friend Toad. I knitted Frog and Toad using lacquered wood size 2 double-pointed needles. It’s hard to get a sense of scale from this photo, but the needles are only six inches long. Like very fancy cocktail toothpicks. Once you’ve worked a few thousand stitches into your muscle memory, you can watch your fingers form stitches almost of their own accord, hypnotically. Right needle opens the stitch and slides in. Left finger wraps the yarn around the needle. Right finger grabs yarn. Right hand pulls the needle back out, a new stitch formed and transferred to the other needle. Over and over, like a meditation in the body. Knitting feels good. It is an intimate, constant reminder that we are a tool-using species with thousands of years of evolution giving us incredible dexterity and the emotional wiring to make us want to use it. An open world game with optimized skill curve#an-open-world-game-with-optimized-skill-curve Of course, you could get much of that same tactile joy by driving to your nearest yarn store and wandering around the aisles jamming your fingers into every ball and skein of yarn they have. (An activity I certainly also do and highly recommend.) Knitting isn’t just about having a hedonistic tactile experience. It is a skilled art with an unbelievably deep lore. I used to be a game programmer, and I tend to look at a lot of activities through the lens of game design. Games are interesting because they are user experience distilled to its essence. When you are, say, using a banking app to transfer some money into savings, there is a user experience in play. But there’s also a utility, a real effect you are trying to have in the material world. You may use your banking app and be satisfied that it helps you save money even if the UX is trash. But a game, almost by definition, doesn’t do anything “out there”. Aside from leaderboards and stuff, the point of a game is to be a low-stakes sandbox where you can play without, you know, accidentally deleting money out of your bank account. Because of that, it’s a pure user experience. All experience and nothing else. Game designers have to make a user experience so good that you don’t care that you aren’t actually accomplishing anything at all with it. That makes the tools they use to think about gameplay experiences useful lenses to evaluate just about any kind of human endeavor. Two aspects (at least) are relevant to knitting. Linearity#linearity A game is “linear” if there’s only one path from beginning to end, one way to play. Don’t particularly enjoy the desert level? Tough shit, you gotta get through it to get to the end. “Nonlinear” games let players choose among multiple paths to reach the end. “Open world” or “sandbox” games blow the gameplay wide open and let players go where they want when they want. The game may not even have an “end”. Knitting is an open world game. There are all sorts of objects you can make out of yarn, and all sorts of styles and techniques to make them. Don’t like socks? Fine, you don’t ever have to go through a “sock making phase” to graduate into what you really want to make. Find stranded colorwork too fiddly? There’s intarsia or just buy a ball of self-striping yarn and let the yarn change colors for you. Is a sweater too big of a commitment? You can make hats forever. I've made a lot of hats. They're quick projects, only take a single skein of yarn, and almost always fit the wearer. The yarn for these hats was a gift from my father and stepmother. I returned the favor by turning some of the yarn into these two hats and giving them back. This was my first time doing stranded colorwork. That means you are holding two yarns at the same time and switching between them frequently. Holding both yarns and the project and the two needles feels overwhelming, but once you get the hang of it, it’s very satisfying being able to switch between the colors at each stitch. Like making analog pixel art. Once you get past the very basics of getting loops on the needles and making stitches, knitting very rarely forces you to slog through something you don’t want to do in order to reach some other goal. Except weaving in ends, I guess, which is kind of a chore. But, honestly, it’s not that bad. Skill curve#skill-curve A skill curve is sort of like a learning curve. It’s an imaginary graph of how much effort it takes to reach greater and more rewarding levels of skill. Some skill curves are steep at the beginning and then flatten out once you’re over the hump. When you first start playing guitar and don’t have the hand strength, callouses, or dexterity to form chords, it’s really hard. But after a few days you can get the basics down. Then it’s pretty easy to learn more and more chords after that. Other skill curves start shallow and get steeper. You can learn chess in a day and have fun playing it, but as you get more serious about it, each incremental increase in skill requires a greater commitment to studying the theory of the game. Knitting has a marvellously smooth, user-controllable skill curve. There’s a small hump at the beginning. It does take a little while to figure out how to hold the needles, control the tension of the yarn with your fingers, and get your hands to work in concert to make stitches. It feels like you’re making a shadow puppet of a sewing machine. Your initial experience will be frustrating. But you can push through that in an hour or so. In a day, you can learn a basic way to cast stitches onto the needles, make knit and purl stitches, and then cast off to finish the work. With just those, you can make scarves and dishcloths. You are a knitter. Working on a swimsuit for Toad. The legs are stockinette stitch, just simple knit stitches over and over, switching colors every few rows. Then the world is your oyster. There are thousands of patterns out there, each listing the techniques required. You can pick ones well inside your comfort zone and grow your skills slowly. Or you can challenge yourself to learn a bunch of techniques at once. There are dozens of tiny little independent tricks to learn, each it’s own little merit badge serotonin hit: long-tail cast on, increases and decreases, cables, etc. So many fun different ways to form stitches. Each is one bite-sized lesson and no matter how many or few you want to chew on at a time, there is a project out there that will satisfy your appetite. I learned how to do a picot bind-off to make these tiny frog fingers. It’s not just the steepness of the curve, but also its height. Some skill curves top out early. I suspect the world’s greatest kazoo player is not profoundly better at kazoo than I am. Others seem to have no limit, like the world’s best violinists or Go players. Friend, the knitting lore goes deep. People have been developing this artform for literally over a thousand years. Knitters have sat there, brain semi-idle, while their fingers worked yarn for millions of hours. They had plenty of time to invent all sorts of crazy ways to tangle yarn up. You could knit full-time for the rest of your life and never run out of new things to learn. Knitting will never stop rewarding you. This pattern taught me short rows, wrap-and-turns, and cable cast-ons. Stuffed animals are typically made using crochet which more easily handles complex organic shapes, but there are some patterns for knitted ones too, like the famous Emotional Support Chicken giving you the side-eye here. Structured but not a game#structured-but-not-a-game To be clear, I’m talking about knitting and videogames as a metaphor. I don’t think of knitting as a game to win. Like a lot of programmers, I am prone to pointing my dumb optimizing brain at random activities and trying to min-max the shit out of it. Ask my wife how many ways to make coffee I have tried to find the optimal effort/reward ratio. When I knit, I do try to knit efficiently. If it takes 10,000 stitches to finish a single scarf, it behooves one to put some thought into the process. At the same time, knitting isn’t just a pointless exercise to scratch my gamification itch. It is a real artform, and I am making real objects. Thousands of stitches, spooled out from time into space. For me, knitting strikes a good balance between structure and unstructure. I like making music and when it goes well, it’s very rewarding. But sometimes I sit down and every melody that comes out sounds like a knock-off NES game (and not in that cool chiptune way). Or I’ll spend an entire evening working on the drum mixing and at the end I can’t tell if I made things better or worse. It’s too unstructured. When I sit down to knit, I might make a mistake that needs unwinding and fixing. But for the most part, I can be confident that an hour spent knitting will get me closer to a beautiful finished object. It provides a reliable serotonin hit of “I’m making progress”. But it’s not so rigidly structured that it triggers my optimizing brain into sucking the joy out of it by turning it into math homework. A time and a space#a-time-and-a-space A particularly nice property of knitting is that it is able to provide that reliable gratification while accommodating all of the other complexities in my life. It takes very little time to make progress knitting. Unlike, say, painting, there’s almost no set up at the beginning of a session or clean up at the end. When I pick up the kids from school, I can get ten minutes of knitting in while I wait for the bell. Working on a blanket square while I wait for the kids. Nor does it take up much space. A zip-loc bag with a ball of yarn and two needles is basically all you need, which is always in my backpack when I get on an airplane now. Conveniently, the TSA specifically allows knitting needles on flights. Note that this only applies to domestic flights in the US. Other countries have their own rules, though I haven’t had any trouble bringing knitting to Denmark or Costa Rica. The fact that the TSA is explicitly fine with you bringing a satchel full of foot-long sharpened metal spikes onto a plane as long as its accompanied by some string really says something about how meaningless these security rules are. Knitting expands and contracts around not just physical space, but headspace too. Had a long day and want something mindless to help you unwind? Slap together a garter stitch scarf and just do the same stitch over and over again. Stressed out by work or grieving a loss and need something consuming to take your mind off it? Start a lacework or cable knit project and the chart and counting won’t leave room to think about anything else. Working on a little dishcloth while sipping hot cocoa. Whatever logistical or mental capacities you have, there is a knitting project that will tuck neatly into it. And then at the end#and-then-at-the-end So far, I’ve been talking about knitting as an activity. A personal hobby to kill time for your own joy. Kill time it does, but knitting isn’t just about whiling away the hours. It’s not playing solitaire or binging a TV show for the fifth time. As yarn spools through your fingers and the hands twirl around the clock, an actual physical, beautiful object emerges at the end. Well, your first couple of objects may not be so beautiful. But even the lumpiest knitted scarf is imbued with something increasingly elusive these days: care. The first real thing I knitted was a scarf for my mother-in-law. In retrospect, I can’t say it’s a great scarf. Kinda cheap acrylic yarn. Not really her color. 4x4 rib was about all I could handle complexity-wise at the time, and it means the scarf tends to bunch up on itself. But when she opened the package on Christmas and saw it, her eyes teared up. Mine are tearing up now writing this. The scarf. Because regardless of how good the object itself is, it is an inarguable testament to the fact that I chose to spend dozens of quiet hours making stitch after stitch, all the while thinking about her and how much she means to me. A fraction of my life’s wick that I burned for her and no one else. In a world where so many seem to want to get more and more out of less and less, to automate and AI-ify everything until an infinite content firehose is blasting into every orifice of every consumer, hand knitting to me is the antidote. An acknowledgement that all we really have is time and thus there is no gift more precious than spending it on someone. Also, once you finish a project, you get to buy more yarn. Because, if I’m honest, a little consumption feels kinda nice too. OK, I’m sold#ok-im-sold Anyway, this is what knitting means to me. Which, now that I read all this, is a lot more than I realized. If that didn’t pique your interest, fine. It’s not for everyone. I do hope you find something out there to spend your time on that provides as much joy as knitting does to me. You deserve that. If this did make you want to give knitting a try, you’re probably wondering what next. Fortunately, there are, like, a million “learn how to knit” tutorials out there. One of the actually marvellous things about living in the world today is good access to lots of videos, and knitting is an activity that’s really hard to convey in book form. You kind of need to watch someone’s hands. Learning from someone in person is best, but if you don’t have that, YouTube is a pretty good substitute. Keep in mind that everyone’s hands are different! There are many ways (a handful, heh heh) to hold the needles and form stitches because our anatomy and the texture of our skin varies so much. Watch a few videos and don’t worry if what works for them doesn’t work for you. Eventually, you’ll find one that does. I knit “Continental style”. That means I hold the working yarn with my left hand and make stitches with my right. In English style knitting, the right hand both holds the yarn and makes stitches. That works well if all of your coordination is in that hand. But as a lefty, I find Continental takes better advantage of the dexterity in both of my hands. Even though I’m left-handed, I knit the same way a right-handed person does. I don’t mirror patterns or make stitches from left to right. If you are sinistral as well, I recommend still learning right-handed. It doesn’t make that much of a difference in terms of dexterity, and it’s much easier if you don’t have to try to mentally mirror every pattern, instruction, and video you watch. Expect to be challenged and frustrated at first. There’s a lot to going on all at once: controlling the tension of the working yarn, keeping the stitches from the previous row on the left needle, keeping the new stitches on the right needle, forming new stitches. This may be one of the first times you’ve used this many of your fingers doing different things all at once. I promise that if you’re patient with yourself and give it a few tries, you will get over the hump. Once you can knit a swatch of garter stitch, everything else will come naturally over time. So go your local craft store, buy a cheap pair of size 7 needles, a ball of worsted (i.e. medium) weight acrylic or wool yarn in a color you think is pretty, and give it a try. The worst that can happen is you’ll waste a few bucks. If you’re lucky, you might end up making your mother-in-law cry (in a good way).
- Access Control SyntaxMay 26, 2025
I’m still tinkering on a scripting language for my hobby fantasy console project. I’m ashamed to admit this, but up to this point, the language had absolutely no notion of modules. Literally every source file is dumped into one big global namespace and compiled together. I always planned to have some sort of module system. I just hadn’t figured it out yet because I had other, harder language design problems to solve. I assumed that the module system mostly didn’t interact with other language features, so I could kick it down the road for now. That was true until it wasn’t. I’ve been beating my head against the wall around generics for… oh God I just checked the Git history and it’s three years now. I still don’t have that pinned down. Parametric types are hard. Anyway, one of the approaches I’m exploring does get tangled up in modules and scoping so now I have to figure modules out. This post is about one little syntax design question I ran into: how do you distinguish public and private declarations? A basic module system#a-basic-module-system Since my language is a scripting language, my ambitions for the module system are pretty minimal. Think more like Python or Dart than Java or C#. Every file has its own top-level scope that isn’t shared with others. If you want to access top-level declarations from another file, you import that file. That makes its top-level declarations available in the importing file. Of course, a module might have some declarations that are only for its own internal use and should not be made available when you import it. A module should be able to encapsulate parts of its implementation. Thus, I need a way for users to indicate which declarations are private and which are public. What other languages do#what-other-languages-do Every language out there has some kind of module system and an ability to control access (though it did take JavaScript about 20 years to get there, bless its heart). In some sense, it’s a solved problem. But they don’t all solve it the same way, especially if you dig into some of the more obscure corners of the language world. Let’s go on a tour… Modifier keywords#modifier-keywords The approach you probably already have in mind is modifiers before declarations. In Java, C#, PHP and others, that’s public, private. Maybe also more specific ones like protected, and internal. It’s clear, explicit, and gets the job done. It lets you support a large number of flavors of access control if you need. It’s also extremely common, so easy for users coming to a new language to pick up. The flip side is that it’s quite verbose. Java is reviled for being too boilerplate-heavy and wordy, and I believe that having private and public scattered throughout every single file is a major contributor to that. Picking the wrong defaults doesn’t help. I used to program in C# professionally for several years and in that time, I can’t recall ever wanting a member to have internal access, which is the default. So I had to write public or private on basically everything. I think the same is true in Java land. Rust improves the situation by having a shorter modifier, pub, and picking what is arguably the right default for a language designed for Serious Programming: private. Modifier sections#modifier-sections C++ has a strange but perhaps underappreciated spin on modifiers. It has access modifier keywords, but they apply to all subsequent declarations. This lets you write a keyword once and apply it to a whole bunch of things. That really does cut down the verbosity. The price to pay is that it also makes the language curiously context-sensitive. If you’re ever defined a preprocessor macro that inserts code in a class and had to be careful where in the class you called the macro, you’ve run into this. Access control sections mean that you can’t look at a single declaration and know what its access is. You have to know what section contains the declaration. There’s also the funny historical thing where members in struct default to public and members in classes default to private. C++ is weird. Like a house built on the back of some Eldritch being whose architecture and plumbing reveals the unholy foundation it is mated with. For presumably less Eldritch reasons, I believe Ada takes a similar approach. Sigils in the names#sigils-in-the-names Modifiers are clear but verbose. If you want something more syntactically economical, why not encode the access control directly in the name of the declaration itself? That’s what Python, Go, and Dart do. Python’s system is a mixture of informal and language supported. A leading underscore in a name doesn’t prohibit it from being used outside of the module but it sends a signal to the user that they shouldn’t use it. Sort of “velvet rope” security. If a class member starts with two leading underscores, then it really is private. The language will name mangle it to make it inaccessible. In Go, if a declaration’s name starts with a capital letter, it’s public. Otherwise, it’s private. Because Go allows any Unicode letter in identifiers, the definition of uppercase is not trivial. Wikipedia tells me there are 1,858 uppercase letters and 2,258 lowercase letters that can be used in identifiers in Go. Fun! Dart only allows ASCII in identifiers and follows Python: If an identifier starts with _, it’s private. Otherwise it’s public. Unlike Python, this is strictly enforced by the language. Private names are completely inaccessible outside of the library where the declaration appears. I don’t have enough Python experience to have an opinion there aside from finding __ pretty long. I work full-time on Dart and have only tinkered in Go so I’m highly biased, but Go’s approach has always felt strange to me. I’m sure it’s mostly habit, but my monkey brain really wants case to be used to distinguish kinds of declarations: leading capitals for types and lowercase for functions. Seeing types with lowercase names or functions with capital names gives me the same squishy feeling in my gut as watching The Polar Express. Dumb subjective biases aside, though, it seems to work fine in practice. I’m much more used to Dart’s style. I don’t love it, but, eh, it gets the job done. These approaches both have the advantage of being very terse. No modifiers, no additional reserved words. There are two strikes against them: They’re obscure. If you’re new to Java and you see the word private, you probably don’t know what it does, but you can guess it has something to do with “privacy”. With Go, if you see that some names are capitalized and some aren’t, that tells you absolutely nothing about what’s going on. Historical baggage? Weird personal preference? Maybe the author is German and prefers capitalizing nouns? Likewise, if you’re skimming some Dart code, why do some of the names start with _? Do you pronounce that when you say the name out loud? You just have to be told what’s going on because the language’s own syntax doesn’t guide you towards an explanation. They show up at the use sites. Access control is a property of a declaration. So, in principle, any syntax for specifying it should only appear at that declaration. When you go to use a name, either you can access it (in which case no syntax is necessary) or you can’t (in which case there’s nothing you could say). But with Go and Dart, every place you mention the name also carries the access control. In theory, this could be a major problem if you want to change the access control of a declaration. You have to fix the identifier at every single use site! In practice, this doesn’t actually cause much pain. If a name is going from private to public, then every existing use site is already confined to one file, so you can rename those without affecting the rest of the program. If an identifier is going from public to private, again all uses must already be confined to that file or you can’t make it private without actually breaking things. I used to say that if I had a time machine, I would go back and change Dart to not use _ for privacy. But I have mellowed on that opinion over the years and now I’m not sure. I don’t like how it shows up at every use site, but it is terse, which is nice. Export manifests#export-manifests Modifers put access control at the declaration. Sigils put it at the declaration and the use sites. The third major approach puts it at neither. Someone will tell me this generalization is wrong but I think most functional languages including the ML family and Lisps (including Scheme) have some kind of separate syntax for listing exports. Within a module, you declare all the functions and types you want without worrying about modularity. Then at the top of the file, the language gives you some dedicated syntax to list the things that should be made visible outside of the module. For example, in Scheme, the syntax for defining a module looks like: (library library-name (export list-of-names-to-export...) declarations-in-library...) SML and its spawn have a notion of a signature which is declared separately from the module. You could also argue that header files in C and C++ are this pattern. A cool thing about export manifests is that they keep all the access control logic in one place. When you’re writing declarations, you don’t have to worry about access control then. You just write declarations. As a user of a library, you can look at just the export section to see what the module lets you do without digging in to the implementation at all. It does a good job of firmly separating interface from implementation. On the other hand, it’s quite verbose. You end up saying the name of every exported declaration twice. Maybe even the full type signatures too. Since the export manifest is separate from the declarations, they have to be manually kept in sync. Rename an exported function and you have to remember to rename it in the manifest too. I ragged on JavaScript for not having its act together, but I gotta say when they did add modules, they came up with a clever blended approach. You can use export as a modifier right on a declaration: export function area(width, height) { return width * height; } But you can also use the export keyword to export a set of declarations that are already declared elsewhere: export { area }; // Code... function area(width, height) { return width * height; } Given that modules were added later to the language, I’m guessing this makes it easier to add modularity to a large existing JS library. Sigils at the declaration#sigils-at-the-declaration In poking around the annals of language history, I found one last approach in Niklaus Wirth’s magnum opus language Oberon. In Oberon, top-level declarations are private by default and only accessible to the containing module. If you want to make a declaration public, you mark the declaration with an asterisk: MODULE Hello; IMPORT Out; PROCEDURE SayHello*; BEGIN Out.String("Hello World!"); Out.Ln; END SayHello; END Hello. Here, the * after PROCEDURE SayHello means that SayHello is public. Note that this marker is only at the declaration. It’s not part of the name. At callsites, the name is just HelloWorld. This has the same inscrutability that Python, Go, and Dart have. If you’re a new Oberon programmer and you see *, you have no idea what it means. Fortunately, there are no new Oberon programmers, so this is not a problem in practice. This approach has the advantage of only appearing at the declaration. You don’t have to repeat the sigil every time you use the thing. Any syntax at all?#any-syntax-at-all I’m sure there are interesting tweaks and refinements in various languages, but I think that mostly covers the approaches in use out there. (If you’re aware of any others, do tell.) Which approach is the right one for my little scripting language? A deeply related question is the choice of defaults. Java has public for public stuff and private for private stuff, but it also has a syntax for package private declarations: nothing at all. If you don’t specify anything, C++ makes your struct members public and your class members private. It’s hard to beat “zero syntax at all” when it comes to brevity, so the choice of what access control a declaration gets by default is an important one. When I put on my rigorous software engineer hat—probably some kind of construction site hard hat—the obvious right default is private. No one can fit an entire huge program in their head, so code needs to be broken into smaller isolated pieces you can reason about locally. Defaulting to private encourages users to make smaller independent modules. If I was making a language for big mission-critical infrastructure software, I would definitely do that. I think Rust made the right choice by defaulting to private and requiring you to opt in to public with pub. But I am making, like, the opposite of that. A hobby project that may never see the light of day for people to make their own hobby games that, realistically, also may never see the light of day. If anything, I want to encourage (hypothetical) users of my (currently mostly vaporware) language to get things done. Access control can get in the way of that. Class modifiers in Dart#class-modifiers-in-dart A few years ago, we added a slew of class modifiers to Dart. These are not directly tied to public and private, but in the process of designing those, I spent a lot of time talking to users about how they use the language and what defaults they prefer. Dart defaults to public. You have to opt in to private by prefixing a name with an underscore. It turns out that most users actually do prefer public being the default. And after looking at mountains of Dart code, what I see is that most Dart code is relatively small applications. Those apps are built on top of a set of frameworks and libraries, but compared to the volume of application code out there, those libraries make up a fairly small fraction of the entire Dart ecosystem. These apps are the leaves of the dependency tree. Nothing depends on them or imports them. The authors of most of those are just trying to slap some UI together and ship a thing. And for most of those, defaulting to public keeps the language out of their way. Syntax for privacy#syntax-for-privacy That all leads me to feel that my scripting language should default to public and have a way to opt in to private. How do to that? No export manifests#no-export-manifests I know I don’t want export manifests. They are much too verbose. Also, there is some extra language complexity and error reporting that gets sucked in. Since the manifest is separate from the declaration, the compiler has to handle cases where you try to export a name that doesn’t exist. My language also has overloading, which means when exporting a function, you would need to a way to specify which of the overloads to export and which to leave private. Export manifests are just way more machinery than fits for a scripting language. This approach is out. No modifier sections#no-modifier-sections The C++ approach is nice in that it’s clear while still being pretty terse. You only need to write a modifier and can apply it to a bunch of declarations in the same scope. But at least in C++ (not sure about Ada), that only works within a type declaration. My language isn’t really object oriented. It’s more procedural. Sort of “structs plus functions” like C. Instead of classes, you have records. But a record declaration tends to be pretty small with only a handful of fields in it. There’s not enough stuff inside a single record to benefit from reusing a modifier for a bunch of nested declarations. I could allow private at the top level and have it apply to everything after, but that feels like it errs too far in the other direction. It would be really confusing to have a thousand line file and not realize that half of it is private because some modifier way offscreen above flipped the access control. Modifier sections are out. No sigils in identifiers#no-sigils-in-identifiers Sigils in identifiers do have a sort of terse, scripty feel. No extra keywords. I can’t do Go’s approach because my language already makes identifier leading case significant. It supports destructuring pattern matching. Like some functional languages, it uses case to distinguish when a pattern is binding a new local variable (lowercase) versus matching against a type or constant (uppercase). Now I’m not sure that corner of the language will stick, but it’s there right now. I could do Python/Dart’s approach and use a leading underscore. But I’d really prefer to not have to mention the sigil at every single use site. After having written probably a million lines of Dart code, I know it’s not intolerable. But it feels like an annoying tax. And for a language I’m designing for my own joy, I’d like to eliminate as many annoyances as possible. Maybe modifiers#maybe-modifiers That leaves two approaches and is where I’m currently at. Approach one is the typical solution to use an access control modifier at the declaration. Unfortunately, defaulting to public makes this harder. In Rust, pub is a really nice little keyword to flip to public when the default is private, but there is no obvious converse. private would be the longest keyword in my language. pri doesn’t read like anything. priv is… strange. I suppose that pvt is the well-established abbreviation for “private” but that feels a tad militaristic. I can’t find any other synonyms for “private” that admit reasonable abbreviations either. So I’m open to taking this path, but I’m simply failing to come up with a good keyword for it. (A silly goblin part of my brain suggested I use shh to mean “private”, like the declaration is being whispered and can’t be heard outside of its module.) Maybe sigils at the declaration#maybe-sigils-at-the-declaration This leaves Oberon’s weird approach. There are several advantages here: It is maximally terse. Zero syntax at the use site, and only a single character at the declaration site. No additional reserved words which might get in the way of user identifiers. By defaulting to public, the opacity of the syntax is less of an issue. Yes, the sigil won’t immediately convey what it does, but if you’re just starting to use the language, you don’t need to even know it exists at first. You can just make everything public. So there is some appeal. I don’t think I can literally take Oberon’s syntax with * following the name. My language lets you define operators and it would be confusing to have * right after other punctuation: def +*(left Vec, right Vec) Vec(left.x + right.x, left.y + right.y) end This is declaring a private + function. It would probably look equally weird to have * after the name in a record declaration: rec Vec* val left Int val right Int end That led me to thinking the sigil should be by the declaration keyword. In my language, every declaration form does start with a leading keyword (unlike, say, C where function declarations don’t have one), so it’s feasible. Something like: rec* Vec val left Int val right Int end def* sayHi() print("Hello!") end I don’t hate it. But using * feels weird for “private”. If anything, it seems to emphasize the declaration (which is what it does in Oberon where it means “public”). Maybe alternate keywords#maybe-alternate-keywords That led to my last idea which I can’t decide if I like or not. Given Python and Dart, it seems like underscore vaguely conveys “private” to some people. So maybe use that? It looks like: rec_ Vec val left Int val right Int end def_ sayHi() print("Hello!") end It looks odd, but I don’t hate it. Or at least I don’t hate it any more than any novel programming language syntax usually triggers revulsion. However, there’s sort of a problem. Underscore is a valid identifier character. So the lexer is not going to scan rec_ as a rec keyword token followed by a _ token. Instead of will treat rec_ as a single identifier. But then… I could just let it do that. I’d then define a separate set of reserved words for all of the private declaration keywords: def_, rec_, var_, and val_. So now instead of a modifier or sigil to control access, you explicitly choose one of two declaration keywords. One makes it public and one makes it private. This technically means adding a handful more reserved words, but they aren’t ones that are particularly useful for users anyway. I keep trying to talk myself out of this approach because it’s so unusual but so far it seems to be lodged in my head better than any of the alternatives I’ve considered. What do you think?
- Does Go Have Subtyping?Oct 19, 2023
I’ve been noodling on a static type system for my current hobby language. To try to keep the language as simple as possible, I’m trying to see if I can live without subtyping. Since most of my programming experience is in object-oriented languages, I’ve been learning more about languages that lack—or at least claim to lack—subyping, to see how they work. The most intriguing one to me is Go because the authors say it doesn’t have subtyping, but when you look at interfaces, it does seem to have something really close to subtyping. Is it subtyping just under another name, or is there really something different going on? This post is the answer to that question as best as I can tell. The short answer is that no, Go doesn’t have subtyping. But also, yes, it sort of does. What is subtyping?#what-is-subtyping If you’re reading my blog, you probably already know what subtyping is, but let’s make sure we’re all starting from the same place. Subtyping defines a relation between two types. Given two types A and B, it might be the case that B is a subtype of A, or it might not be. Since subtyping is a relation between a pair of types, it only comes into play in places in code where two types are involved. The main place is assignment. You have an expression of type A and you assign the result to a variable with type B. Is that assignment allowed? Programming language folks usually generalize “assignment” to mean any place where a variable is given some value. That includes assignment expressions, but also covers initialized variable declarations and function calls where argument values passed to the function are bound to their corresponding parameters. There are a couple of other places where subtyping comes into play, usually around type inference, but assignment is the main one: You have a context that requires some type B and a value of some type A. What are the types A and B where that code is valid? That question is the heart of what a type checker does. The main user interface of a static system is compile errors, and the most common compile error is “I expected a value of this type but you gave me a value of this other type”. Why have subtyping?#why-have-subtyping You have a context that expects type B and you give it a value of type A. In languages without subtyping, that’s only OK if A and B are the exact same type. In Pascal, if you declare a variable with type integer, the only thing you can initialize it with is a value of type integer. Subtyping exists largely to loosen that restriction—to allow multiple different types to flow into some context. Why might a language want to permit that? The reason is polymorphism: Subtyping lets you write a piece of code and reuse that same code with a range of different (but related) types. In languages without subtyping, you can often find yourself copy/pasting the same function to work with multiple different input types. (Generics can help, but that’s another form of polymorphism that we’ll ignore for this post.) In, say, Java, if you define a method that takes an Iterable, then you can pass a List to it, a Stack, etc. You get to amortize the usefulness of that method across all types that implement the Iterable interface. Subtyping is a force multiplier for your code. (Of course, that benefit isn’t without significant costs in terms of language complexity, which is why I’m hoping to avoid it.) Does Go have subtyping?#does-go-have-subtyping If you search the (extremely well-written!) Go language spec for “subtype”, you get zero results. So the answer is a clear “no” at the textual level. However, we needer a deeper hermeneutics. Java does have subtyping. Now, if you were to make a new language named “Blava” that was a literal copy/paste of the Java language specification with every use of “subtype” replaced with “blubtype”, would you say that Blava has subtyping? It behaves indistinguishably from a language with subtyping, so I’d be inclined to say yes. The Go spec doesn’t mention “subtype”, but it does have a notion of “assignability”. When you have a context that expects some type and you give it a value of some other type, assignability determines which set of other types are allowed. Concretely, the rules are: A non-interface type T is assignable to an interface type I if T implements I. An interface type A is assignable to interface type B if A’s methods are a superset of B’s. You know, that sounds an awful lot like subtyping. Is “assignable to” just Rob Pike’s idiosyncratic way of saying “subtype of”? Does Go have subtyping in everything except name? Are we just playing semantics? (I mean, we’re designing a programming language, so obviously everything we do is playing semantics. But I mean are we playing semantics with the language spec itself?) To fully answer that, we’ll need to look at all of the kinds of types in a program. Composite types and variance#composite-types-and-variance If the only types in Go’s type system were primitives like numbers, structs, and interfaces then I think you’d have a good argument that Go does have subtyping, just spelled differently. But once you start looking at slice types and function types, the story changes. (And array and channel types too, but slices and functions are enough to make the point.) The thing that these latter kinds of types have in common is that they contain other types. A slice type has an inner type for the slice elements. A function type has a list of parameter types and a list of return types. You ready for some more computer science jargon? We’ve been talking about relations on pairs of types like “is subtype” and “is assignable”. But now we have types that contain other types. That raises the question of whether a relation on the inner types of two composite types says anything about the relation between the two outer types. For example, let’s say we have two slice types []E1 and []E2. They have element types E1 and E2, respectively. If E1 is assignable to E2 does that mean that []E1 is assignable to []E2? Does the assignability “propagate” from the inner types to the outer types? Computer scientists call this property (meta-property?) variance. They phrase the question like “how does assignability of slice types vary with respect to their element types?”. There are a few possible answers to a question like this. Variance of slice types#variance-of-slice-types For slice types in Go specifically, there are a handful of assignability rules, but the only one that applies to slice types is: V and T are identical. In other words, for two slice types to be assignable, they have to be the exact same type. That in turn means they must have the exact same element types. Even if two element types are assignable, slices of those two types are not. Judging by an endless series of confused people asking questions on StackOverflow, that behavior is unintuitive to programmers, both in Go and in other languages. Let’s say you have this Go program: type Dog struct { name string } type Barker interface { Bark() } func (d Dog) Bark() { fmt.Println("Woof!") } Here we have a Dog concrete type, which is assignable to the interface Barker. So this is fine: func speak(barker Barker) { barker.Bark() } func main() { speak(Dog{"Sparky"}) } Given that, you might expect this to work too: func speakAll(barkers []Barker) { for _, barker := range barkers { barker.Bark() } } func main() { dogs := []Dog{Dog{"Sparky"}, Dog{"Fido"}} speakAll(dogs) } But, no. The type system giveth and the type system taketh away: example.go:29:11: cannot use dogs (variable of type []Dog) as []Barker value in argument to speakAll If the type system didn’t yell at you, this program would be fine at runtime. All it does is call Bark() on every element in the array, and both Sparky and Fido do implement that method. So what’s the deal? In this case, the program is coincidentally fine because speakAll() is only reading from the slice. But what if we wrote: type Tree struct { species string } func (t Tree) Bark() { fmt.Println("Rough (but not ruff)!") } func appendTree(barkers []Barker) []Barker { return append(barkers, Tree{"Elm"}) } There’s nothing wrong with this appendTree() function. It adds a Tree to the given slice. Since Tree is assignable to Barker, that’s fine. But if you were to call this and pass in a []Dog, you’d end up with an array of dogs that had a tree stuck in it! That would violate the soundness of the language. This is why Go only treats two slice types as assignable if they have the exact same element types. In PL parlance, slice types are invariant with respect to their element types. And, for a mutable data structure like slices, that rule makes sense. (A reasonable person might wonder then why Java and C# don’t have this rule and instead say that array types are assignable if their element types are. And then, because as you can see, it isn’t safe to do so, they have to add runtime checks if you try to stuff an element of the wrong type into the array.) So, OK, it makes sense for slice (and array) types to be invariant. What about function types? Variance of function types#variance-of-function-types To keep things simple, first we’ll consider just functions that don’t take any parameters and have a single return type. Given two function types like that, when are they assignable? Again, the only rule in the Go language spec that matches function types is V and T are identical. So two function types are only assignable if they have the exact same return types. Even if the return types are themselves assignable, if they are different types, the functions aren’t assignable. Do we need to be that strict to preserve soundness? Actually, no! Here’s an example: func returnDog() Dog { return Dog{"Rex"} } func useCallback(callback func() Barker) { barker := callback() barker.Bark() } func main() { useCallback(returnDog) } So we have a function, returnDog that returns a value of type Dog. We pass a reference to that function to useCallback() whic expects a function that returns a Barker. The Dog type does implement Barker. If this program were run, it would be perfectly safe. And, in fact, there’s nothing you could put inside useCallback() that would make passing returnDog to it violate the soundness of the type system. It’s sound and semantically kosher in principle… but Go disallows it: ./prog.go:49:14: cannot use returnDog (value of type func() Dog) as func() Barker value in argument to useCallback Every other language I know that has subtyping and function types allows this. A function type A is a subtype of another function type B if the return type of A is a subtype of the return type of B. So the subtyping relation of the return types propagates out to determine the subtype relation of the function types. We call this covariance and say that function types are covariant in their return types. The “co-” prefix means that the subtyping relation between the inner types goes in the “same direction” as the subtyping relation it implies about the outer types. That direction matters because relations like subtyping and assignability aren’t symmetric. The Dog type is assignable to Barker, but Barker is not assignable to Dog. The underlying value might be a Tree! Are there cases where the variance of an inner type doesn’t go in the same direction as the outer types? Indeed there are, and they’re right there next to us. Instead of return types, let’s look at parameter types. Now let’s say we only care about functions that accept a single parameter and return nothing. Here’s an example: func acceptBarker(barker Barker) { barker.Bark() } func useCallback(callback func(Dog)) { callback(Dog{"Laika"}) } func main() { useCallback(acceptBarker) } Note that the parameter types are flipped compared to the return type example. Here, the callback type in useCallback() takes a more precise type of Dog. The function we pass to it, acceptBarker has a parameter whose type is Barker. You may feel a slight disorientation here. The code feels weird and sort of backwards. Wait a minute and the dizziness will pass. Dramamine might help. While this definitely isn’t as intuitive as return types being covariant, if you think about it carefully, you’ll see that the above program is completely sound. In other languages with subtyping, function type A is a subtype of function type B if the parameter types of B are subtypes of the parameter types of A. Note how A and B are reversed in the second half of that sentence. The variance of parameter types is reversed. In technical terms, we say that function types are contravariant in their parameter types. The prefix “contra-” means “against”. (You might wonder what happens when you have a function type with a parameter whose type is itself a function type with some parameter type. How does that flow out? When there’s two levels of nesting it flips around to going in the same direction as the outermost type. The way I think about it is that contravariance is a 180° flip in the direction of the relation. If you nest contravariant types, you flip it twice and get back to the original direction.) Contravariant parameter types are sound, but again Go doesn’t allow them. Two function types are only assignable if their parameter types are exactly the same. Invariance in Go#invariance-in-go In every language I know with subtyping, function types are covariant in their return types and contravariant in their parameter types. But in Go, function types are invariant. Go is not a language known for getting in the programmer’s way when they want to do something, so why are function types more restrictive than would be necessary for soundness? It’s not just function types either. All composite types are invariant in Go: arrays, slices, channels, maps, functions. So ground types—types that don’t contain any other type—have some subtype-like notion of assignability. But once you wrap a type in another, any notion of assignability goes away. Why did the designers of Go do that? If you’re going to bother having interfaces and assignability, why not go all the way and have assignability for functions and other composite types where its sound? If all the designers cared about was semantic correctness and having a beautiful elegant specification written in LaTeX, then they probably would have supported variance, at least for functions. (The other types all should be invariant since they are mutable. When types can flow both in and out, any other variance isn’t sound.) But Go was designed from day one to be a high-performance systems language. It’s the exact opposite of an ivory tower language designed for proofs and publications. The goal of the language is to let real users ship real applications. And, importantly, be able to ship fast applications and reason about the performance of their code. Representing values#representing-values Up to this point, we’ve only been concerned with how types flow through the type checker at compile time. But—assuming there are no compile errors—the compiler eventually excretes some machine code which gets executed at runtime. When that happens, all of the types the type checker poked at have cracked out of their chrysalides and emerged as beautiful runtime value butterflies flitting around in memory. The choice of how values of different types are represented in memory has a massive effect on performance. So how do the rules around assignability and subtyping interact with those representation choices? In many object-oriented languages (Java, C#, Python, etc.) values of object types are represented by pointers to a heap-allocated structure. That structure has some header information for garbage collection and runtime type tracking, maybe some kind of pointer to a vtable for virtual method dispatch, then (finally!) the memory used to store the instance’s fields. There are differences between language implementations, of course, but objects are generally both: Slow to create since they are allocated on the heap. Fairly large with some additional bookkeeping information stored for every single object. Indirect, where a variable or field whose type is an object holds only a pointer to that object, which is always on the heap. Accessing state on an object always requires a pointer indirection which can be slow due to poor locality and cache misses. Struct types#struct-types Those are unnacceptable costs for a systems language like Go. When you want runtime polymorphism, of course, you have to pay for it somehow. But if you’re just storing data in memory, Go doesn’t want to make you pay for something you aren’t using. To that end values of struct types in Go store just the bytes needed for the struct’s own fields. If a field of a struct is itself some struct type, the inner struct’s fields are splatted directly into the surrounding struct’s contiguous memory. If you have a local variable of a struct type, the fields are stored right on the stack (unless you take a pointer to the struct which escapes the function). This reduces memory overhead for structs and (probably more importantly for performance) reduces pointer indirections. In a typical Java program, the heap ends up being a huge spiderweb of tiny objects all pointing to each other and the poor CPU an exhausted spider traipsing all over that web trying to find the actual bits of data it wants to eat. In a typical Go program, more state is stored directly on the stack, and the heap is “chunkier” with fewer, larger blobs of memory. The CPU does fewer hops around the heap and chews on bigger data insects every time it does. That makes memory access more cache friendly and also lightens the load on the garbage collector since there are fewer individual allocations to traverse. (Java does something similar for primitive types, as does C# for struct types.) Interface types#interface-types So structs are fast, great. But Go does feature runtime polymorphism in the form of interfaces. How does interface method dispatch work if a value is stored directly inline with no extra data to track its runtime type or method implementations? The answer is that interfaces have a completely different runtime representation. A variable of interface type takes up two words: A pointer to the type information used for runtime dispatch of the interface methods (in other words, basically a vtable). A pointer to the actual data used by the concrete type implementing the interface. (In cases where the data is just a single word, I think it’s stored inline.) The cute industry term for a representation like this is fat pointer: instead of a single word with a single pointer, it’s a pair of them, one for data and one for some kind of metadata or bookkeeping information. One of the really cool things about Go is that you only use this representation—you only pay for the increased memory and indirection cost of this representation—when you ask for it and when you need it. In places where you need some virtual dispatch, you use an interface type and accept the overhead of a fat pointer and indirection. But in places where you just want to store a single concrete type, you use its underlying type and the memory is stored directly inline. C# supports a similar distinction with classes and structs. But that’s mostly a “declaration time” choice. Once you’ve decided something is a class, every variable of that class’s type will store it as a reference to a heap-allocated object. Conversely, if you’ve declared something as a struct, it will always be stored inline on the stack or in the containing object (unless you go out of your way to box it). In Go, the distinction between stored inline versus stored indirectly is made at each use site. That leads to some additional complexity for the user: they always have to think “should I use an interface, pointer, or struct type here?”, but it gives them more fine-grained control over how they spend memory and pointer indirection costs. Implicit conversions#implicit-conversions We’re close to understanding why Go lets you assign a struct to an interface but not a slice of those same structs to a slice of that same interface. If structs and interfaces have entirely different memory representations, how does assignability work at all? When you do: type Dog struct{ name string } type Barker interface { Bark() } func main() { var barker Barker = Dog{"Rex"} } Shouldn’t that just mangle memory when it tries to treat the memory representation of a struct as if it were an interface? The answer is of course no. When compiling your code, Go knows the type of every variable and every expression. At every assignment, variable declaration, or parameter binding, it reports an error if the value isn’t assignable to the destination. When the value is assignable, the compiler also knows whether or not those types are the same. If they’re exactly identical, then the assignment can be compiled to a single register move or memory copy. When they are different but still assignable types, the compiler silently inserts code to convert the value type’s memory representation to the destination type’s representation. When you assign a value of a struct type to an interface type, the compiler inserts code to build a fat pointer, wire up its method table pointer to the right interface implementation, move the struct’s data onto the heap, etc. Likewise, if you assign one interface type to another, the compiler inserts code to copy the data pointers over but look up the correct method table for the destination interface given the type information of the value’s interface type. This right here is the reason that all composite types are invariant in Go. When assigning a single value to a related but different type, the compiler can easily insert fixed-cost code to convert the value’s runtime representation to the destination type’s. But to convert a slice of some struct type to a slice of an interface type would require an O(n) traversal of the entire slice to convert each element. Function types are even harder. In order to support covariant return types and contravariant parameter types, the compiler would need to insert conversion code somewhere, but there’s no right place to put it. Putting it inside the function itself doesn’t work because it might be called with a variety of different parameter types and we don’t know what to convert it from. Putting it at the callsite before the parameters are passed likewise wouldn’t work because we don’t know what types every callback might require. There is potentially something clever you could do by supporting multiple entrypoints to functions for each pair of source and destination types, but with multiple parameters you quickly run the risk of exponential code size explosions. This is why languages that do support subtyping and variance almost always have a uniform memory representation for all objects that participate in the subtype hierarchy. Does Go have subtyping?#does-go-have-subtyping If you make it this far, congratulations. This ended up being a much deeper dive than I expected. I learned a lot exploring this corner of the language space and I hope you learned something too. Getting back to the original question, I think we could accurately describe Go’s subtyping story in two equivalent ways: Yes, Go has subtyping, but it has no support for variance and all composite types are invariant. This is, I think, how someone who is focused only on the abstract semantics of the language would describe it. If you were writing papers about type systems and needed to model Go’s you might adopt this perspective. If you didn’t care about how Go could be efficiently implemented because you were treating it purely as an abstraction, then this is a good way to look at it and compare it to other languages. The main problem with the looking at the language this way is that it obscures why every composite type is invariant. No, Go doesn’t have subtyping, but it does have implicit conversions between some pairs of types. This is how the designers of Go describe the language. It’s the way you’d want to look at the language if you were tasked with sitting down and writing a production quality implementation of it. It describes what the language actually does mechanically at compile time and runtime. The challenge I found with this perspective is that it made it harder for me to relate Go’s design choices to other more explicitly object-oriented languages. You can look at this entire long article as my process of trying to figure out the first interpretation in terms of this one. I started digging into this not because I’m an active Go user and want to know what’s going on under the hood. My job and hobby is designing programming languages, so I want to know how other languages work to see what good ideas are out there to be harvested. So the question always on my mind at this point is, “Why did they design it this way and does that choice make sense in other languages?” And for this specific design choice, I think it’s pretty cool. You can imagine a language wanting three things: Non-uniform representation: Values in memory take up only as much space as they need and avoid pointer indirection when possible to maximize runtime efficiency. Polymorphism: The ability to reuse code to work with a range of values of different types. Variance: Sort of the “lifted” form of polymorphism: The ability to reuse code to work with composite types that contain a range of inner types. Those are all nice to have features, but it’s really hard to get all three at once. Most object-oriented languages sacrifice the first one to get the other two. That gives you flexibility and expressiveness but at a pervasive runtime cost spread throughout the entire program. Some statically-typed languages with simpler type systems like C, Pascal, and SML (ignoring modules, which are a whole other thing) give up polymorphism and variance which can give you more efficient representations at the cost of less code reuse. Languages like C++ and Rust more or less give you all three at the expense of the compiler monomorphizing and generating specialized versions of a function for the different types it gets passed, which makes compilation much slower and can have some runtime costs from all of the extra code sitting around in memory. Go is aiming for a sweet spot where they give you fast compiles, efficient runtime execution, and as much flexibility as they can get away with. It sacrifices variance but keeps polymorphism at the individual value level. That married with implicit conversions enables non-uniform representation. Of the three, variance is probably the least valuable for users, so I think that’s a pretty smart trade-off.
Dave Cheney
- Pop quiz: what time was it?Dec 18, 2025
Here’s a small quiz derived from some incorrect advice from an AI coding assistant. This program prints two timestamps; will they be a. Roughly the same time (ie, the same second)b. Roughly 10 seconds apartc. Something else Answer after the fold
- Pointer pop quizNov 27, 2025
Here’s a silly example extracted from real code. Does this program print true or false?
- Microblog: TestMain can cause one to question realityFeb 21, 2024
This morning a one line change had several of us tearing up the fabric of reality trying to understand why a failing test wasn’t failing, or, in fact, being run at all. Increasingly frantic efforts to upgrade/downgrade Go, run the tests on another machine, run the tests in CI, all served to only unnerve us […]