diff --git a/src/apps/cam/file_sink.cpp b/src/apps/cam/file_sink.cpp
index dca350c4..3db59925 100644
--- a/src/apps/cam/file_sink.cpp
+++ b/src/apps/cam/file_sink.cpp
@@ -16,6 +16,7 @@
 #include <libcamera/camera.h>
 
 #include "../common/dng_writer.h"
+#include "../common/pnm_writer.h"
 #include "../common/image.h"
 
 #include "file_sink.h"
@@ -73,6 +74,7 @@ void FileSink::writeBuffer(const Stream *stream, FrameBuffer *buffer,
 	if (!pattern_.empty())
 		filename = pattern_;
 
+	bool pnm = filename.find(".pnm", filename.size() - 4) != std::string::npos;
 #ifdef HAVE_TIFF
 	bool dng = filename.find(".dng", filename.size() - 4) != std::string::npos;
 #endif /* HAVE_TIFF */
@@ -90,6 +92,15 @@ void FileSink::writeBuffer(const Stream *stream, FrameBuffer *buffer,
 
 	Image *image = mappedBuffers_[buffer].get();
 
+	if (pnm) {
+		ret = PNMWriter::write(filename.c_str(), stream->configuration(),
+				       image->data(0).data());
+		if (ret < 0)
+			std::cerr << "failed to write PNM file `" << filename
+				  << "'" << std::endl;
+
+		return;
+	}
 #ifdef HAVE_TIFF
 	if (dng) {
 		ret = DNGWriter::write(filename.c_str(), camera_,
diff --git a/src/apps/cam/main.cpp b/src/apps/cam/main.cpp
index 179cc376..cdaa0baa 100644
--- a/src/apps/cam/main.cpp
+++ b/src/apps/cam/main.cpp
@@ -150,6 +150,8 @@ int CamApp::parseOptions(int argc, char *argv[])
 			 "to write files, using the default file name. Otherwise it sets the\n"
 			 "full file path and name. The first '#' character in the file name\n"
 			 "is expanded to the camera index, stream name and frame sequence number.\n"
+			 "If the file name ends with '.pnm', then the frame will be written to\n"
+			 "the output file(s) in PNM format.\n"
 #ifdef HAVE_TIFF
 			 "If the file name ends with '.dng', then the frame will be written to\n"
 			 "the output file(s) in DNG format.\n"
diff --git a/src/apps/common/meson.build b/src/apps/common/meson.build
index 479326cd..47b7e41a 100644
--- a/src/apps/common/meson.build
+++ b/src/apps/common/meson.build
@@ -3,6 +3,7 @@
 apps_sources = files([
     'image.cpp',
     'options.cpp',
+    'pnm_writer.cpp',
     'stream_options.cpp',
 ])
 
diff --git a/src/apps/common/pnm_writer.cpp b/src/apps/common/pnm_writer.cpp
new file mode 100644
index 00000000..b505f97e
--- /dev/null
+++ b/src/apps/common/pnm_writer.cpp
@@ -0,0 +1,54 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2024 Red Hat, Inc.
+ *
+ * pnm_writer.cpp - PNM writer
+ */
+
+#include "pnm_writer.h"
+
+#include <fstream>
+#include <iostream>
+
+#include <libcamera/formats.h>
+#include <libcamera/pixel_format.h>
+
+using namespace libcamera;
+
+int PNMWriter::write(const char *filename,
+		     const StreamConfiguration &config,
+		     const void *data)
+{
+	if (config.pixelFormat != formats::BGR888) {
+		std::cerr << "Only BGR888 output pixel format is supported ("
+			  << config.pixelFormat << " requested)" << std::endl;
+		return -EINVAL;
+	}
+
+	std::ofstream output(filename, std::ios::binary);
+	if (!output) {
+		std::cerr << "Failed to open pnm file: " << filename << std::endl;
+		return -EINVAL;
+	}
+
+	output << "P6" << std::endl
+	       << config.size.width << " " << config.size.height << std::endl
+	       << "255" << std::endl;
+	if (!output) {
+		std::cerr << "Failed to write the file header" << std::endl;
+		return -EINVAL;
+	}
+
+	const unsigned int rowLength = config.size.width * 3;
+	const unsigned int paddedRowLength = config.stride;
+	const char *row = reinterpret_cast<const char *>(data);
+	for (unsigned int y = 0; y < config.size.height; y++, row += paddedRowLength) {
+		output.write(row, rowLength);
+		if (!output) {
+			std::cerr << "Failed to write image data at row " << y << std::endl;
+			return -EINVAL;
+		}
+	}
+
+	return 0;
+}
diff --git a/src/apps/common/pnm_writer.h b/src/apps/common/pnm_writer.h
new file mode 100644
index 00000000..a0ae4587
--- /dev/null
+++ b/src/apps/common/pnm_writer.h
@@ -0,0 +1,18 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2020, Raspberry Pi Ltd
+ *
+ * pnm_writer.h - PNM writer
+ */
+
+#pragma once
+
+#include <libcamera/stream.h>
+
+class PNMWriter
+{
+public:
+	static int write(const char *filename,
+			 const libcamera::StreamConfiguration &config,
+			 const void *data);
+};
