diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index a52219a52896ab6382d6ab72148da5288d041124..53f2e7b4ce54b88a9e45df329eb9a7348829330c 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -33,6 +33,7 @@ stages:
   - build_test
   - example
   - build_test_example
+  - python
   - install
   - optional
 
@@ -542,3 +543,38 @@ sanity:
     untracked: true
     policy: pull
     key: "${CI_COMMIT_REF_SLUG}-gcc"
+
+##########################################################
+# template for all Python jobs
+.python:
+  stage: python
+  tags:
+    - corsika
+  before_script:
+    - apt-get update && apt-get install -y python3-pip
+  script:
+    - cd ${CI_PROJECT_DIR}/python  # change into the Python directory
+    - pip3 install --user -e '.[test]'  # install the package + test deps
+    - python3 --version
+    - python3 -m mypy corsika
+    - python3 -m isort --atomic --check-only corsika tests
+    - python3 -m black -t py37 corsika tests
+    - python3 -m flake8 corsika tests
+    - python3 -m pytest --cov=corsika tests
+    - cd ${CI_PROJECT_DIR}  # reset the directory
+  coverage: '/^TOTAL\s*\d+\s*\d+\s*(.*\%)/'
+
+# the default Python version Ubuntu 18.04 is Python3.8
+python-3.8:
+  extends: .python
+  image: corsika/devel:u-18.04
+  dependencies:
+    - build-u-18_04
+  cache:
+    key: "${CI_COMMIT_REF_SLUG}-gcc"
+  artifacts:
+    when: always
+    expire_in: 1 year
+    paths:
+      - ${CI_PROJECT_DIR}/Python/python-test.log
+  allow_failure: true
diff --git a/.gitmodules b/.gitmodules
index 1bbe5d4d093457574b332f922f45b43b2d2f8b53..5429f2d75f591d52f001615fddde188e8bd012de 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -12,4 +12,4 @@
 	path = modules/conex
 	url = ../../AirShowerPhysics/cxroot.git
 	branch = master
-        shallow = true
+	shallow = true
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 74c59086064789144e328c4d1bc911c410658d5e..d81de38f705fa7f6b5901a5643e74cb2f5f9ab93 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -105,6 +105,7 @@ add_compile_options (
 include (conan) # self-provided in 'cmake' directory
 #
 # download and build all dependencies
+message (STATUS "Installing dependencies from Conan (this may take some time)...")
 conan_cmake_run (CONANFILE conanfile.txt
                  BASIC_SETUP CMAKE_TARGETS
                  BUILD missing
@@ -210,6 +211,8 @@ target_link_libraries (
   CONAN_PKG::eigen
   CONAN_PKG::spdlog
   CONAN_PKG::boost
+  CONAN_PKG::yaml-cpp
+  CONAN_PKG::arrow
   cnpy # for SaveBoostHistogram
   )
 
diff --git a/MAINTAINERS.md b/MAINTAINERS.md
index a57e4bfbbbff4526527819a3f2e4840c58a9b799..2da518cf5b6b9a8e0e3a278bf166fd246137554d 100644
--- a/MAINTAINERS.md
+++ b/MAINTAINERS.md
@@ -23,6 +23,13 @@ Hadron models:
 - Felix Riehn <friehn@lip.pt>, Santiago/Lisbon
 - Anatoli Fedynitch <anatoli.fedynitch@icecube.wisc.edu> ICRR Tokyo
 
+Output formats and infrastructure:
+- Remy Prechelt <prechelt@hawaii.edu>, UHM
+- Ralf Ulrich <ralf.ulrich@kit.edu>, KIT
+
+Python library:
+- Remy Prechelt <prechelt@hawaii.edu>, UHM
+
 Radio:
 - Remy Prechelt <prechelt@hawaii.edu> 
 - Tim Huege <tim.huege@kit.edu>, KIT
diff --git a/corsika/detail/framework/core/Cascade.inl b/corsika/detail/framework/core/Cascade.inl
index 32127908c714d79dfdd7e22e74615aed449b1b7c..654dfc6d1aad0b603baa76e701f5357fa85ffc13 100644
--- a/corsika/detail/framework/core/Cascade.inl
+++ b/corsika/detail/framework/core/Cascade.inl
@@ -26,11 +26,14 @@
 
 namespace corsika {
 
-  template <typename TTracking, typename TProcessList, typename TStack,
+  template <typename TTracking, typename TProcessList, typename TOutput, typename TStack,
             typename TStackView>
-  inline void Cascade<TTracking, TProcessList, TStack, TStackView>::run() {
+  inline void Cascade<TTracking, TProcessList, TOutput, TStack, TStackView>::run() {
     setNodes(); // put each particle on stack in correct environment volume
 
+    // start this event (i.e. this shower)
+    output_.startOfShower();
+
     while (!stack_.isEmpty()) {
       while (!stack_.isEmpty()) {
         CORSIKA_LOG_TRACE("Stack: {}", stack_.asString());
@@ -51,11 +54,15 @@ namespace corsika {
       // thus, the double loop
       // doCascadeEquations();
     }
+
+    // end this event (i.e. this shower)
+    output_.endOfShower();
   }
 
-  template <typename TTracking, typename TProcessList, typename TStack,
+  template <typename TTracking, typename TProcessList, typename TOutput, typename TStack,
             typename TStackView>
-  inline void Cascade<TTracking, TProcessList, TStack, TStackView>::forceInteraction() {
+  inline void
+  Cascade<TTracking, TProcessList, TOutput, TStack, TStackView>::forceInteraction() {
     CORSIKA_LOG_TRACE("forced interaction!");
     setNodes();
     auto vParticle = stack_.getNextParticle();
@@ -65,9 +72,9 @@ namespace corsika {
     vParticle.erase(); // primary particle is done
   }
 
-  template <typename TTracking, typename TProcessList, typename TStack,
+  template <typename TTracking, typename TProcessList, typename TOutput, typename TStack,
             typename TStackView>
-  inline void Cascade<TTracking, TProcessList, TStack, TStackView>::step(
+  inline void Cascade<TTracking, TProcessList, TOutput, TStack, TStackView>::step(
       Particle& vParticle) {
 
     // determine combined total interaction length (inverse)
@@ -258,9 +265,10 @@ namespace corsika {
     vParticle.erase();
   }
 
-  template <typename TTracking, typename TProcessList, typename TStack,
+  template <typename TTracking, typename TProcessList, typename TOutput, typename TStack,
             typename TStackView>
-  inline ProcessReturn Cascade<TTracking, TProcessList, TStack, TStackView>::decay(
+  inline ProcessReturn
+  Cascade<TTracking, TProcessList, TOutput, TStack, TStackView>::decay(
       TStackView& view, InverseTimeType initial_inv_decay_time) {
     CORSIKA_LOG_DEBUG("decay");
 
@@ -289,11 +297,11 @@ namespace corsika {
     return returnCode;
   }
 
-  template <typename TTracking, typename TProcessList, typename TStack,
+  template <typename TTracking, typename TProcessList, typename TOutput, typename TStack,
             typename TStackView>
-  inline ProcessReturn Cascade<TTracking, TProcessList, TStack, TStackView>::interaction(
+  inline ProcessReturn
+  Cascade<TTracking, TProcessList, TOutput, TStack, TStackView>::interaction(
       TStackView& view, InverseGrammageType initial_inv_int_length) {
-
     CORSIKA_LOG_DEBUG("collide");
 
 #ifdef DEBUG
@@ -322,9 +330,9 @@ namespace corsika {
     return returnCode;
   }
 
-  template <typename TTracking, typename TProcessList, typename TStack,
+  template <typename TTracking, typename TProcessList, typename TOutput, typename TStack,
             typename TStackView>
-  inline void Cascade<TTracking, TProcessList, TStack, TStackView>::setNodes() {
+  inline void Cascade<TTracking, TProcessList, TOutput, TStack, TStackView>::setNodes() {
     std::for_each(stack_.begin(), stack_.end(), [&](auto& p) {
       auto const* numericalNode =
           environment_.getUniverse()->getContainingNode(p.getPosition());
@@ -332,9 +340,9 @@ namespace corsika {
     });
   }
 
-  template <typename TTracking, typename TProcessList, typename TStack,
+  template <typename TTracking, typename TProcessList, typename TOutput, typename TStack,
             typename TStackView>
-  inline void Cascade<TTracking, TProcessList, TStack, TStackView>::setEventType(
+  inline void Cascade<TTracking, TProcessList, TOutput, TStack, TStackView>::setEventType(
       TStackView& view, [[maybe_unused]] history::EventType eventType) {
     if constexpr (TStackView::has_event) {
       for (auto&& sec : view) { sec.getEvent()->setEventType(eventType); }
diff --git a/corsika/detail/framework/utility/CubicSolver.inl b/corsika/detail/framework/utility/CubicSolver.inl
index 3a68a3d6ebcf39290807c3402c09fcfccfe8ab6e..c15594e02a02bef2683e4d3ae4d4ad02895ef747 100644
--- a/corsika/detail/framework/utility/CubicSolver.inl
+++ b/corsika/detail/framework/utility/CubicSolver.inl
@@ -248,10 +248,6 @@ namespace corsika {
     long double x1 = xinfl;
     long double f_x1 = cubic_function(xinfl, a, b, c, d);
 
-    CORSIKA_LOG_TRACE("dist={} xinfl={} f_x1={} {} {}", dist, xinfl, f_x1,
-                      cubic_function(xinfl - 2 / 3 * std::sqrt(dist), a, b, c, d),
-                      cubic_function(xinfl + 2 / 3 * std::sqrt(dist), a, b, c, d));
-
     if (std::abs(f_x1) > epsilon) {
       if (std::abs(dist) < epsilon) {
         x1 = xinfl - std::cbrt(f_x1);
diff --git a/corsika/detail/modules/ObservationPlane.inl b/corsika/detail/modules/ObservationPlane.inl
index e5be30bedf2f81b65d68faeca314669c58c31ac6..12614573f13888396cc28e3655b9119fa7af2bec 100644
--- a/corsika/detail/modules/ObservationPlane.inl
+++ b/corsika/detail/modules/ObservationPlane.inl
@@ -6,33 +6,26 @@
  * the license.
  */
 
-#include <corsika/modules/ObservationPlane.hpp>
-#include <corsika/framework/core/Logging.hpp>
+#include <corsika/setup/SetupStack.hpp>
 #include <corsika/setup/SetupTrajectory.hpp>
 
-#include <fstream>
-
 namespace corsika {
 
-  inline ObservationPlane::ObservationPlane(Plane const& obsPlane,
-                                            DirectionVector const& x_axis,
-                                            std::string const& filename, bool deleteOnHit)
+  template <typename TOutput>
+  ObservationPlane<TOutput>::ObservationPlane(Plane const& obsPlane,
+                                              DirectionVector const& x_axis,
+                                              bool deleteOnHit)
       : plane_(obsPlane)
-      , outputStream_(filename)
       , deleteOnHit_(deleteOnHit)
       , energy_ground_(0_GeV)
       , count_ground_(0)
       , xAxis_(x_axis.normalized())
-      , yAxis_(obsPlane.getNormal().cross(xAxis_)) {
-    CORSIKA_LOG_DEBUG("Plane height: {}", obsPlane.getCenter());
-    outputStream_ << "#PDG code, energy / eV, x distance / m, y distance / m"
-                  << std::endl;
-  }
+      , yAxis_(obsPlane.getNormal().cross(xAxis_)) {}
 
-  inline ProcessReturn ObservationPlane::doContinuous(
+  template <typename TOutput>
+  inline ProcessReturn ObservationPlane<TOutput>::doContinuous(
       corsika::setup::Stack::particle_type& particle, corsika::setup::Trajectory&,
       bool const stepLimit) {
-
     /*
        The current step did not yet reach the ObservationPlane, do nothing now and wait:
      */
@@ -53,9 +46,9 @@ namespace corsika {
     Point const pointOfIntersection = particle.getPosition();
     Vector const displacement = pointOfIntersection - plane_.getCenter();
 
-    outputStream_ << static_cast<int>(get_PDG(particle.getPID())) << ' ' << energy / 1_eV
-                  << ' ' << displacement.dot(xAxis_) / 1_m << ' '
-                  << displacement.dot(yAxis_) / 1_m << '\n';
+    // add our particles to the output file stream
+    this->write(particle.getPID(), energy, displacement.dot(xAxis_),
+                displacement.dot(yAxis_));
 
     CORSIKA_LOG_TRACE("Particle detected absorbed={}", deleteOnHit_);
 
@@ -68,7 +61,8 @@ namespace corsika {
     }
   }
 
-  inline LengthType ObservationPlane::getMaxStepLength(
+  template <typename TOutput>
+  inline LengthType ObservationPlane<TOutput>::getMaxStepLength(
       corsika::setup::Stack::particle_type const& particle,
       corsika::setup::Trajectory const& trajectory) {
 
@@ -93,17 +87,62 @@ namespace corsika {
     return dist;
   }
 
-  inline void ObservationPlane::showResults() const {
+  template <typename TOutput>
+  inline void ObservationPlane<TOutput>::showResults() const {
     CORSIKA_LOG_INFO(
         " ******************************\n"
         " ObservationPlane: \n"
-        " energy in ground (GeV)     :  {}\n"
-        " no. of particles in ground :  {}\n"
+        " energy an ground (GeV)     :  {}\n"
+        " no. of particles at ground :  {}\n"
         " ******************************",
         energy_ground_ / 1_GeV, count_ground_);
   }
 
-  inline void ObservationPlane::reset() {
+  template <typename TOutput>
+  inline YAML::Node ObservationPlane<TOutput>::getConfig() const {
+    using namespace units::si;
+
+    // construct the top-level node
+    YAML::Node node;
+
+    // basic info
+    node["type"] = "ObservationPlane";
+    node["units"] = "m"; // add default units for values
+
+    // the center of the plane
+    auto const center{plane_.getCenter()};
+
+    // save each component in its native coordinate system
+    auto const center_coords{center.getCoordinates(center.getCoordinateSystem())};
+    node["plane"]["center"].push_back(center_coords.getX() / 1_m);
+    node["plane"]["center"].push_back(center_coords.getY() / 1_m);
+    node["plane"]["center"].push_back(center_coords.getZ() / 1_m);
+
+    // the normal vector of the plane
+    auto const normal{plane_.getNormal().getComponents()};
+    node["plane"]["normal"].push_back(normal.getX().magnitude());
+    node["plane"]["normal"].push_back(normal.getY().magnitude());
+    node["plane"]["normal"].push_back(normal.getZ().magnitude());
+
+    // the x-axis vector
+    auto const xAxis_coords{xAxis_.getComponents(xAxis_.getCoordinateSystem())};
+    node["x-axis"].push_back(xAxis_coords.getX().magnitude());
+    node["x-axis"].push_back(xAxis_coords.getY().magnitude());
+    node["x-axis"].push_back(xAxis_coords.getZ().magnitude());
+
+    // the y-axis vector
+    auto const yAxis_coords{yAxis_.getComponents(yAxis_.getCoordinateSystem())};
+    node["y-axis"].push_back(yAxis_coords.getX().magnitude());
+    node["y-axis"].push_back(yAxis_coords.getY().magnitude());
+    node["y-axis"].push_back(yAxis_coords.getZ().magnitude());
+
+    node["delete_on_hit"] = deleteOnHit_;
+
+    return node;
+  }
+
+  template <typename TOutput>
+  inline void ObservationPlane<TOutput>::reset() {
     energy_ground_ = 0_GeV;
     count_ground_ = 0;
   }
diff --git a/corsika/detail/modules/TrackWriter.inl b/corsika/detail/modules/TrackWriter.inl
index e55d40cb9cfb64d23aaccd21fc11347e10035226..8af3c31dc45b718d8213346e3896e2dffb02812d 100644
--- a/corsika/detail/modules/TrackWriter.inl
+++ b/corsika/detail/modules/TrackWriter.inl
@@ -8,59 +8,48 @@
 
 #pragma once
 
-#include <corsika/modules/TrackWriter.hpp>
-
 #include <corsika/framework/core/ParticleProperties.hpp>
+#include <corsika/framework/core/PhysicalUnits.hpp>
 
-#include <corsika/setup/SetupStack.hpp>
-#include <corsika/setup/SetupTrajectory.hpp>
-
-#include <iomanip>
 #include <limits>
 
 namespace corsika {
 
-  inline TrackWriter::TrackWriter(std::string const& filename)
-      : filename_(filename) {
-    using namespace std::string_literals;
-
-    file_.open(filename_);
-    file_
-        << "# PID, E / eV, start coordinates / m, displacement vector to end / m, steplength / m "s
-        << '\n';
-  }
-
-  inline TrackWriter::~TrackWriter() { file_.close(); }
+  template <typename TOutputWriter>
+  inline TrackWriter<TOutputWriter>::TrackWriter() {}
 
+  template <typename TOutputWriter>
   template <typename TParticle, typename TTrack>
-  inline ProcessReturn TrackWriter::doContinuous(TParticle const& vP, TTrack const& vT,
-                                                 bool const) {
-
-    CORSIKA_LOG_DEBUG("TrackWriter");
+  inline ProcessReturn TrackWriter<TOutputWriter>::doContinuous(TParticle const& vP,
+                                                                TTrack const& vT,
+                                                                bool const) {
 
     auto const start = vT.getPosition(0).getCoordinates();
-    auto const delta = vT.getPosition(1).getCoordinates() - start;
-    auto const pdg = static_cast<int>(get_PDG(vP.getPID()));
+    auto const end = vT.getPosition(1).getCoordinates();
 
-    // clang-format off
-    file_ << std::setw(7) << pdg
-          << std::setw(width_) << std::scientific << std::setprecision(precision_) << vP.getEnergy() / 1_eV
-          << std::setw(width_) << std::scientific << std::setprecision(precision_) << start[0] / 1_m 
-          << std::setw(width_) << std::scientific << std::setprecision(precision_) << start[1] / 1_m
-          << std::setw(width_) << std::scientific << std::setprecision(precision_) << start[2] / 1_m
-          << std::setw(width_) << std::scientific << std::setprecision(precision_) << delta[0] / 1_m
-          << std::setw(width_) << std::scientific << std::setprecision(precision_) << delta[1] / 1_m
-          << std::setw(width_) << std::scientific << std::setprecision(precision_) << delta[2] / 1_m 
-          << std::setw(width_) << std::scientific << std::setprecision(precision_) << delta.getNorm() / 1_m
-          << '\n';
-    // clang-format on
+    // write the track to the file
+    this->write(vP.getPID(), vP.getEnergy(), start, end);
 
     return ProcessReturn::Ok;
   }
 
+  template <typename TOutputWriter>
   template <typename TParticle, typename TTrack>
-  inline LengthType TrackWriter::getMaxStepLength(TParticle const&, TTrack const&) {
+  inline LengthType TrackWriter<TOutputWriter>::getMaxStepLength(TParticle const&,
+                                                                 TTrack const&) {
     return meter * std::numeric_limits<double>::infinity();
   }
 
+  template <typename TOutputWriter>
+  YAML::Node TrackWriter<TOutputWriter>::getConfig() const {
+    using namespace units::si;
+
+    YAML::Node node;
+
+    // add default units for values
+    node["type"] = "TrackWriter";
+    node["units"] = "GeV | m";
+
+    return node;
+  }
 } // namespace corsika
diff --git a/corsika/detail/modules/writers/ObservationPlaneWriterParquet.inl b/corsika/detail/modules/writers/ObservationPlaneWriterParquet.inl
new file mode 100644
index 0000000000000000000000000000000000000000..60752c56a9fed06b326449530c2d24d5837d233e
--- /dev/null
+++ b/corsika/detail/modules/writers/ObservationPlaneWriterParquet.inl
@@ -0,0 +1,54 @@
+/*
+ * (c) Copyright 2021 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+
+#pragma once
+
+namespace corsika {
+
+  inline ObservationPlaneWriterParquet::ObservationPlaneWriterParquet()
+      : output_() {}
+
+  inline void ObservationPlaneWriterParquet::startOfLibrary(
+      boost::filesystem::path const& directory) {
+
+    // setup the streamer
+    output_.initStreamer((directory / "particles.parquet").string());
+
+    // enable compression with the default level
+    output_.enableCompression();
+
+    // build the schema
+    output_.addField("pdg", parquet::Repetition::REQUIRED, parquet::Type::INT32,
+                     parquet::ConvertedType::INT_32);
+    output_.addField("energy", parquet::Repetition::REQUIRED, parquet::Type::FLOAT,
+                     parquet::ConvertedType::NONE);
+    output_.addField("x", parquet::Repetition::REQUIRED, parquet::Type::FLOAT,
+                     parquet::ConvertedType::NONE);
+    output_.addField("y", parquet::Repetition::REQUIRED, parquet::Type::FLOAT,
+                     parquet::ConvertedType::NONE);
+
+    // and build the streamer
+    output_.buildStreamer();
+  }
+
+  inline void ObservationPlaneWriterParquet::endOfShower() { ++shower_; }
+
+  inline void ObservationPlaneWriterParquet::endOfLibrary() { output_.closeStreamer(); }
+
+  inline void ObservationPlaneWriterParquet::write(Code const& pid,
+                                                   HEPEnergyType const& energy,
+                                                   LengthType const& x,
+                                                   LengthType const& y) {
+    // write the next row - we must write `shower_` first.
+    *(output_.getWriter()) << shower_ << static_cast<int>(get_PDG(pid))
+                           << static_cast<float>(energy / 1_GeV)
+                           << static_cast<float>(x / 1_m) << static_cast<float>(y / 1_m)
+                           << parquet::EndRow;
+  }
+
+} // namespace corsika
diff --git a/corsika/detail/modules/writers/TrackWriterParquet.inl b/corsika/detail/modules/writers/TrackWriterParquet.inl
new file mode 100644
index 0000000000000000000000000000000000000000..26c62bdf0e5e8260c431dc114aaed25e4aab0a82
--- /dev/null
+++ b/corsika/detail/modules/writers/TrackWriterParquet.inl
@@ -0,0 +1,68 @@
+/*
+ * (c) Copyright 2021 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+
+#pragma once
+
+namespace corsika {
+
+  inline TrackWriterParquet::TrackWriterParquet()
+      : output_() {}
+
+  inline void TrackWriterParquet::startOfLibrary(
+      boost::filesystem::path const& directory) {
+
+    // setup the streamer
+    output_.initStreamer((directory / "tracks.parquet").string());
+
+    // build the schema
+    output_.addField("pdg", parquet::Repetition::REQUIRED, parquet::Type::INT32,
+                     parquet::ConvertedType::INT_32);
+    output_.addField("energy", parquet::Repetition::REQUIRED, parquet::Type::FLOAT,
+                     parquet::ConvertedType::NONE);
+    output_.addField("start_x", parquet::Repetition::REQUIRED, parquet::Type::FLOAT,
+                     parquet::ConvertedType::NONE);
+    output_.addField("start_y", parquet::Repetition::REQUIRED, parquet::Type::FLOAT,
+                     parquet::ConvertedType::NONE);
+    output_.addField("start_z", parquet::Repetition::REQUIRED, parquet::Type::FLOAT,
+                     parquet::ConvertedType::NONE);
+    output_.addField("end_x", parquet::Repetition::REQUIRED, parquet::Type::FLOAT,
+                     parquet::ConvertedType::NONE);
+    output_.addField("end_y", parquet::Repetition::REQUIRED, parquet::Type::FLOAT,
+                     parquet::ConvertedType::NONE);
+    output_.addField("end_z", parquet::Repetition::REQUIRED, parquet::Type::FLOAT,
+                     parquet::ConvertedType::NONE);
+
+    // and build the streamer
+    output_.buildStreamer();
+  }
+
+  inline void TrackWriterParquet::endOfShower() { ++shower_; }
+
+  inline void TrackWriterParquet::endOfLibrary() { output_.closeStreamer(); }
+
+  inline void TrackWriterParquet::write(Code const& pid, HEPEnergyType const& energy,
+                                        QuantityVector<length_d> const& start,
+                                        QuantityVector<length_d> const& end) {
+
+    // write the next row - we must write `shower_` first.
+    // clang-format off
+    *(output_.getWriter())
+        << shower_
+        << static_cast<int>(get_PDG(pid))
+        << static_cast<float>(energy / 1_GeV)
+        << static_cast<float>(start[0] / 1_m)
+        << static_cast<float>(start[1] / 1_m)
+        << static_cast<float>(start[2] / 1_m)
+        << static_cast<float>(end[0] / 1_m)
+        << static_cast<float>(end[1] / 1_m)
+        << static_cast<float>(end[2] / 1_m)
+        << parquet::EndRow;
+    // clang-format on
+  }
+
+} // namespace corsika
diff --git a/corsika/detail/output/BaseOutput.inl b/corsika/detail/output/BaseOutput.inl
new file mode 100644
index 0000000000000000000000000000000000000000..24ec861ac9c2b3083ea834261f7fa0a57fd8559e
--- /dev/null
+++ b/corsika/detail/output/BaseOutput.inl
@@ -0,0 +1,15 @@
+/*
+ * (c) Copyright 2021 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+#pragma once
+
+namespace corsika {
+
+  inline BaseOutput::BaseOutput()
+      : shower_(0) {}
+
+} // namespace corsika
diff --git a/corsika/detail/output/DummyOutputManager.inl b/corsika/detail/output/DummyOutputManager.inl
new file mode 100644
index 0000000000000000000000000000000000000000..acf2b69f987dbebeb69c9a77438141afc36a036d
--- /dev/null
+++ b/corsika/detail/output/DummyOutputManager.inl
@@ -0,0 +1,27 @@
+/*
+ * (c) Copyright 2021 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+#pragma once
+
+namespace corsika {
+
+  inline DummyOutputManager::DummyOutputManager() {}
+
+  inline DummyOutputManager::~DummyOutputManager() {}
+
+  template <typename TOutput>
+  inline void DummyOutputManager::add(std::string const&, TOutput&) {}
+
+  inline void DummyOutputManager::startOfLibrary() {}
+
+  inline void DummyOutputManager::startOfShower() {}
+
+  inline void DummyOutputManager::endOfShower() {}
+
+  inline void DummyOutputManager::endOfLibrary() {}
+
+} // namespace corsika
diff --git a/corsika/detail/output/OutputManager.inl b/corsika/detail/output/OutputManager.inl
new file mode 100644
index 0000000000000000000000000000000000000000..6a4ec62045acddbf9028f27d9991239c63012553
--- /dev/null
+++ b/corsika/detail/output/OutputManager.inl
@@ -0,0 +1,238 @@
+/*
+ * (c) Copyright 2021 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+#pragma once
+
+#include <algorithm>
+#include <fstream>
+#include <functional>
+
+#include <iomanip>
+#include <ctime>
+#include <sstream>
+
+#include <boost/filesystem.hpp>
+
+#include <fmt/core.h>
+#include <fmt/chrono.h>
+
+namespace corsika {
+
+  inline void OutputManager::writeNode(YAML::Node const& node,
+                                       boost::filesystem::path const& path) const {
+
+    // construct a YAML emitter for this config file
+    YAML::Emitter out;
+
+    // and write the node to the output
+    out << node;
+
+    // open the output file - this is <output name>.yaml
+    boost::filesystem::ofstream file(path);
+
+    // dump the YAML to the file
+    file << out.c_str() << std::endl;
+  }
+
+  inline void OutputManager::writeTopLevelConfig() const {
+
+    YAML::Node config;
+
+    // some basic info
+    config["name"] = name_;               // the simulation name
+    config["creator"] = "CORSIKA8";       // a tag to identify C8 libraries
+    config["version"] = "8.0.0-prealpha"; // the current version
+
+    // write the node to a file
+    writeNode(config, root_ / ("config.yaml"));
+  }
+
+  inline void OutputManager::writeTopLevelSummary() const {
+
+    YAML::Node config;
+
+    // the total number of showers contained in the library
+    config["showers"] = count_;
+
+    // this next section handles writing some time and duration information
+
+    // create a quick lambda function to convert a time-instance to a string
+    auto timeToString = [&](auto const time) -> std::string {
+      // ISO 8601 time format
+      auto format{"%FT%T%z"};
+
+      // convert the clock to a time_t
+      auto time_tc{std::chrono::system_clock::to_time_t(time)};
+
+      // create the string and push the time onto it
+      std::ostringstream oss;
+      oss << std::put_time(std::localtime(&time_tc), format);
+
+      return oss.str();
+    };
+
+    auto end_time{std::chrono::system_clock::now()};
+
+    // now let's construct an estimate of the runtime
+    auto runtime{end_time - start_time};
+
+    // add the time and duration info
+    config["start time"] = timeToString(start_time);
+    config["end time"] = timeToString(end_time);
+    config["runtime"] = fmt::format("{:%H:%M:%S}", runtime);
+
+    // write the node to a file
+    writeNode(config, root_ / ("summary.yaml"));
+  }
+
+  inline void OutputManager::initOutput(std::string const& name) const {
+    // construct the path to this directory
+    auto const path{root_ / name};
+
+    // create the directory for this process.
+    boost::filesystem::create_directory(path);
+
+    // get the config for this output
+    auto config = outputs_.at(name).get().getConfig();
+
+    // and assign the name for this output
+    config["name"] = name;
+
+    // write the config for this output to the file
+    writeNode(config, path / "config.yaml");
+  }
+
+  inline OutputManager::OutputManager(
+      std::string const& name,
+      boost::filesystem::path const& dir = boost::filesystem::current_path())
+      : name_(name)
+      , root_(dir / name) {
+
+    // check if this directory already exists
+    if (boost::filesystem::exists(root_)) {
+      logger->error("Output directory '{}' already exists! Do not overwrite!.",
+                    root_.string());
+      throw std::runtime_error("Output directory already exists.");
+    }
+
+    // construct the directory for this library
+    boost::filesystem::create_directories(root_);
+
+    // write the top level config file
+    writeTopLevelConfig();
+  }
+
+  inline OutputManager::~OutputManager() {
+
+    if (state_ == OutputState::ShowerInProgress) {
+      // if this the destructor is called before the shower has been explicitly
+      // ended, print a warning and end the shower before continuing.
+      logger->warn(
+          "OutputManager was destroyed before endOfShower() called."
+          " The last shower in this libray may be incomplete.");
+      endOfShower();
+    }
+
+    // write the top level summary file (summary.yaml)
+    writeTopLevelSummary();
+
+    // if we are being destructed but EndOfLibrary() has not been called,
+    // make sure that we gracefully close all the outputs. This is a supported
+    // method of operation so we don't issue a warning here
+    if (state_ == OutputState::LibraryReady) { endOfLibrary(); }
+  }
+
+  template <typename TOutput>
+  inline void OutputManager::add(std::string const& name, TOutput& output) {
+
+    // check if that name is already in the map
+    if (outputs_.count(name) > 0) {
+      logger->error("'{}' is already registered. All outputs must have unique names.",
+                    name);
+      throw std::runtime_error("Output already exists. Do not overwrite!");
+    }
+
+    // if we get here, the name is not already in the map
+    // so we create the output and register it into the map
+    outputs_.insert(std::make_pair(name, std::ref(output)));
+
+    // and initialize this output
+    initOutput(name);
+  }
+
+  inline void OutputManager::startOfLibrary() {
+
+    // this is only valid when we haven't started a library
+    // or have already finished a library
+    if (!(state_ == OutputState::NoInit || state_ == OutputState::LibraryFinished)) {
+
+      throw std::runtime_error("startOfLibrary() called in invalid state.");
+    }
+
+    // we now forward this signal to all of our outputs
+    for (auto& [name, output] : outputs_) {
+
+      // construct the path to this output subdirectory
+      auto const path{root_ / name};
+
+      // and start the library
+      output.get().startOfLibrary(path);
+    }
+
+    // we have now started running
+    state_ = OutputState::LibraryReady;
+  }
+
+  inline void OutputManager::startOfShower() {
+
+    // if this is called and we still in the "no init" state, then
+    // this is the first shower in the library so make sure we start it
+    if (state_ == OutputState::NoInit) { startOfLibrary(); }
+
+    // now start the event for all the outputs
+    for (auto& [name, output] : outputs_) { output.get().startOfShower(); }
+
+    // increment our shower count
+    ++count_;
+
+    // and transition to the in progress state
+    state_ = OutputState::ShowerInProgress;
+  }
+
+  inline void OutputManager::endOfShower() {
+
+    for (auto& [name, output] : outputs_) { output.get().endOfShower(); }
+
+    // switch back to the initialized state
+    state_ = OutputState::LibraryReady;
+  }
+
+  inline void OutputManager::endOfLibrary() {
+
+    // we can only call endOfLibrary when we have already started
+    if (state_ == OutputState::NoInit) {
+      throw std::runtime_error("endOfLibrary() called in invalid state.");
+    }
+
+    // write the summary for each output and forward the endOfLibrary call()
+    for (auto& [name, output] : outputs_) {
+
+      // we get the summary for each output as a YAML node
+      auto summary{outputs_.at(name).get().getSummary()};
+
+      // write the summary for this output to the file
+      writeNode(summary, root_ / name / "summary.yaml");
+
+      // and forward the end of library call
+      output.get().endOfLibrary();
+    }
+
+    // and the library has finished
+    state_ = OutputState::LibraryFinished;
+  }
+
+} // namespace corsika
diff --git a/corsika/detail/output/ParquetStreamer.inl b/corsika/detail/output/ParquetStreamer.inl
new file mode 100644
index 0000000000000000000000000000000000000000..d0bb2f9e01a42fef34c00fd2d41c189e60f252e2
--- /dev/null
+++ b/corsika/detail/output/ParquetStreamer.inl
@@ -0,0 +1,71 @@
+/*
+ * (c) Copyright 2021 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+
+#pragma once
+
+namespace corsika {
+
+  inline ParquetStreamer::ParquetStreamer()
+      : isInit_(false) {}
+
+  inline void ParquetStreamer::initStreamer(std::string const& filepath) {
+
+    // open the file and connect it to our pointer
+    PARQUET_ASSIGN_OR_THROW(outfile_, arrow::io::FileOutputStream::Open(filepath));
+
+    // the default builder settings
+    builder_.created_by("CORSIKA8");
+
+    // add run and event tags to the file
+    addField("shower", parquet::Repetition::REQUIRED, parquet::Type::INT32,
+             parquet::ConvertedType::INT_32);
+  }
+
+  template <typename... TArgs>
+  inline void ParquetStreamer::addField(TArgs&&... args) {
+    fields_.push_back(parquet::schema::PrimitiveNode::Make(args...));
+  }
+
+  inline void ParquetStreamer::enableCompression(int const /*level*/) {
+    // builder_.compression(parquet::Compression::ZSTD);
+    // builder_.compression_level(level);
+  }
+
+  inline void ParquetStreamer::buildStreamer() {
+
+    // build the top level schema
+    schema_ = std::static_pointer_cast<parquet::schema::GroupNode>(
+        parquet::schema::GroupNode::Make("schema", parquet::Repetition::REQUIRED,
+                                         fields_));
+
+    // and build the writer
+    writer_ = std::make_shared<parquet::StreamWriter>(
+        parquet::ParquetFileWriter::Open(outfile_, schema_, builder_.build()));
+
+    //  only now this object is ready to stream
+    isInit_ = true;
+  }
+
+  inline void ParquetStreamer::closeStreamer() {
+    writer_.reset();
+    [[maybe_unused]] auto status = outfile_->Close();
+    isInit_ = false;
+  }
+
+  inline std::shared_ptr<parquet::StreamWriter> ParquetStreamer::getWriter() {
+    if (!isInit()) {
+      throw std::runtime_error(
+          "ParquetStreamer not initialized. Either 1) add the "
+          "corresponding module to "
+          "the OutputManager, or 2) declare the module to write no output using "
+          "NoOutput.");
+    }
+    return writer_;
+  }
+
+} // namespace corsika
diff --git a/corsika/framework/core/Cascade.hpp b/corsika/framework/core/Cascade.hpp
index b9ee5361bdd00301a1ffe2f2c4ecd75563379ec2..1eadbce1160a405f6cc1738d62997a20923aab3a 100644
--- a/corsika/framework/core/Cascade.hpp
+++ b/corsika/framework/core/Cascade.hpp
@@ -57,7 +57,7 @@ namespace corsika {
    *
    *
    */
-  template <typename TTracking, typename TProcessList, typename TStack,
+  template <typename TTracking, typename TProcessList, typename TOutput, typename TStack,
             /*
              TStackView is needed as explicit template parameter because
              of issue 161 and the
@@ -84,10 +84,11 @@ namespace corsika {
     ~Cascade() = default;
     Cascade& operator=(Cascade const&) = default;
     Cascade(Environment<MediumInterface> const& env, TTracking& tr, TProcessList& pl,
-            TStack& stack)
+            TOutput& out, TStack& stack)
         : environment_(env)
         , tracking_(tr)
         , sequence_(pl)
+        , output_(out)
         , stack_(stack) {
       CORSIKA_LOG_INFO(c8_ascii_);
       CORSIKA_LOG_INFO("Tracking algorithm: {} (version {})", TTracking::getName(),
@@ -139,6 +140,7 @@ namespace corsika {
     Environment<MediumInterface> const& environment_;
     TTracking& tracking_;
     TProcessList& sequence_;
+    TOutput& output_;
     TStack& stack_;
     default_prng_type& rng_ = RNGManager::getInstance().getRandomStream("cascade");
     unsigned int count_ = 0;
diff --git a/corsika/modules/ObservationPlane.hpp b/corsika/modules/ObservationPlane.hpp
index f7ccba999c531333dbe8dedf9b7550c97f09ba19..613708d1090b19f6cb314f09f62e837ee2e55e20 100644
--- a/corsika/modules/ObservationPlane.hpp
+++ b/corsika/modules/ObservationPlane.hpp
@@ -13,9 +13,7 @@
 #include <corsika/framework/process/ContinuousProcess.hpp>
 #include <corsika/setup/SetupStack.hpp>
 #include <corsika/setup/SetupTrajectory.hpp>
-
-#include <fstream>
-#include <string>
+#include <corsika/modules/writers/ObservationPlaneWriterParquet.hpp>
 
 namespace corsika {
 
@@ -34,11 +32,12 @@ namespace corsika {
      small gap in between the two plane in such a scenario, or develop
      another more specialized output class.
    */
-  class ObservationPlane : public ContinuousProcess<ObservationPlane> {
+  template <typename TOutputWriter = ObservationPlaneWriterParquet>
+  class ObservationPlane : public ContinuousProcess<ObservationPlane<TOutputWriter>>,
+                           public TOutputWriter {
 
   public:
-    ObservationPlane(Plane const&, DirectionVector const&, std::string const&,
-                     bool = true);
+    ObservationPlane(Plane const&, DirectionVector const&, bool = true);
 
     ProcessReturn doContinuous(corsika::setup::Stack::particle_type& vParticle,
                                corsika::setup::Trajectory& vTrajectory,
@@ -50,10 +49,10 @@ namespace corsika {
     void showResults() const;
     void reset();
     HEPEnergyType getEnergyGround() const { return energy_ground_; }
+    YAML::Node getConfig() const;
 
   private:
     Plane const plane_;
-    std::ofstream outputStream_;
     bool const deleteOnHit_;
     HEPEnergyType energy_ground_;
     unsigned int count_ground_;
diff --git a/corsika/modules/TrackWriter.hpp b/corsika/modules/TrackWriter.hpp
index 8b9b0efe7faca74ade9f9c55ea463186bc111e98..79e1dfc91bbb52d08300699fc3e6ee1e098feb8a 100644
--- a/corsika/modules/TrackWriter.hpp
+++ b/corsika/modules/TrackWriter.hpp
@@ -8,19 +8,17 @@
 
 #pragma once
 
-#include <corsika/framework/core/PhysicalUnits.hpp>
 #include <corsika/framework/process/ContinuousProcess.hpp>
-
-#include <fstream>
-#include <string>
+#include <corsika/modules/writers/TrackWriterParquet.hpp>
 
 namespace corsika {
 
-  class TrackWriter : public ContinuousProcess<TrackWriter> {
+  template <typename TOutputWriter = TrackWriterParquet>
+  class TrackWriter : public ContinuousProcess<TrackWriter<TOutputWriter>>,
+                      public TOutputWriter {
 
   public:
-    TrackWriter(std::string const& filename);
-    ~TrackWriter();
+    TrackWriter();
 
     template <typename TParticle, typename TTrack>
     ProcessReturn doContinuous(TParticle const&, TTrack const&, bool const limitFlag);
@@ -28,12 +26,7 @@ namespace corsika {
     template <typename TParticle, typename TTrack>
     LengthType getMaxStepLength(TParticle const&, TTrack const&);
 
-  private:
-    std::string const filename_;
-    std::ofstream file_;
-
-    int width_ = 14;
-    int precision_ = 6;
+    YAML::Node getConfig() const;
   };
 
 } // namespace corsika
diff --git a/corsika/modules/writers/ObservationPlaneWriterParquet.hpp b/corsika/modules/writers/ObservationPlaneWriterParquet.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..2a5089c26f129adb6a5281b8474c509096de3ab6
--- /dev/null
+++ b/corsika/modules/writers/ObservationPlaneWriterParquet.hpp
@@ -0,0 +1,59 @@
+/*
+ * (c) Copyright 2021 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+
+#pragma once
+
+#include <corsika/output/BaseOutput.hpp>
+#include <corsika/output/ParquetStreamer.hpp>
+#include <corsika/framework/core/ParticleProperties.hpp>
+#include <corsika/framework/core/PhysicalUnits.hpp>
+
+namespace corsika {
+
+  class ObservationPlaneWriterParquet : public BaseOutput {
+
+    ParquetStreamer output_; ///< The primary output file.
+
+  public:
+    /**
+     * Construct an ObservationPlane.
+     *
+     * @param name    The name of this output.
+     */
+    ObservationPlaneWriterParquet();
+
+    /**
+     * Called at the start of each library.
+     */
+    void startOfLibrary(boost::filesystem::path const& directory) final override;
+
+    /**
+     * Called at the end of each shower.
+     */
+    void endOfShower() final override;
+
+    /**
+     * Called at the end of each library.
+     *
+     * This must also increment the run number since we override
+     * the default behaviour of BaseOutput.
+     */
+    void endOfLibrary() final override;
+
+  protected:
+    /**
+     * Write a particle to the file.
+     */
+    void write(Code const& pid, units::si::HEPEnergyType const& energy,
+               units::si::LengthType const& x, units::si::LengthType const& y);
+
+  }; // class ObservationPlaneWriterParquet
+
+} // namespace corsika
+
+#include <corsika/detail/modules/writers/ObservationPlaneWriterParquet.inl>
diff --git a/corsika/modules/writers/TrackWriterParquet.hpp b/corsika/modules/writers/TrackWriterParquet.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..7caf90558b2ae0fc5c1cc13ca38b7d009e70b335
--- /dev/null
+++ b/corsika/modules/writers/TrackWriterParquet.hpp
@@ -0,0 +1,62 @@
+/*
+ * (c) Copyright 2021 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+
+#pragma once
+
+#include <corsika/output/BaseOutput.hpp>
+#include <corsika/output/ParquetStreamer.hpp>
+#include <corsika/framework/core/ParticleProperties.hpp>
+#include <corsika/framework/core/PhysicalUnits.hpp>
+#include <corsika/framework/geometry/QuantityVector.hpp>
+
+namespace corsika {
+
+  class TrackWriterParquet : public BaseOutput {
+
+  public:
+    /**
+     * Construct a new writer.
+     *
+     * @param name    The name of this output.
+     */
+    TrackWriterParquet();
+
+    /**
+     * Called at the start of each library.
+     */
+    void startOfLibrary(boost::filesystem::path const& directory) final override;
+
+    /**
+     * Called at the end of each shower.
+     */
+    void endOfShower() final override;
+
+    /**
+     * Called at the end of each library.
+     *
+     * This must also increment the run number since we override
+     * the default behaviour of BaseOutput.
+     */
+    void endOfLibrary() final override;
+
+  protected:
+    /**
+     * Write a track to the file.
+     */
+    void write(Code const& pid, units::si::HEPEnergyType const& energy,
+               QuantityVector<length_d> const& start,
+               QuantityVector<length_d> const& end);
+
+  private:
+    ParquetStreamer output_; ///< The primary output file.
+
+  }; // class TrackWriterParquet
+
+} // namespace corsika
+
+#include <corsika/detail/modules/writers/TrackWriterParquet.inl>
diff --git a/corsika/output/BaseOutput.hpp b/corsika/output/BaseOutput.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..0024c5b7a918e4705fbbadd3bdcdd59dfcab0449
--- /dev/null
+++ b/corsika/output/BaseOutput.hpp
@@ -0,0 +1,75 @@
+/*
+ * (c) Copyright 2021 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+#pragma once
+
+#include <boost/filesystem.hpp>
+
+#include <yaml-cpp/yaml.h>
+
+namespace corsika {
+
+  /**
+   * This is the base class for all outputs so that they
+   * can be stored in homogeneous containers.
+   */
+  class BaseOutput {
+
+  protected:
+    BaseOutput();
+
+  public:
+    /**
+     * Called at the start of each run.
+     */
+    virtual void startOfLibrary(boost::filesystem::path const& directory) = 0;
+
+    /**
+     * Called at the start of each event/shower.
+     */
+    virtual void startOfShower() {}
+
+    /**
+     * Called at the end of each event/shower.
+     */
+    virtual void endOfShower() = 0;
+
+    /**
+     * Called at the end of each run.
+     */
+    virtual void endOfLibrary() = 0;
+
+    /**
+     * Get the configuration of this output.
+     */
+    virtual YAML::Node getConfig() const = 0;
+
+    /**
+     * Get any summary information for the entire library.
+     */
+    virtual YAML::Node getSummary() { return YAML::Node(); }
+
+    /**
+     * Flag to indicate readiness.
+     */
+    bool isInit() const { return is_init_; }
+
+  protected:
+    /**
+     * Set init flag.
+     */
+    void setInit(bool const v) { is_init_ = v; }
+
+    int shower_{0}; ///< The current event number.
+
+  private:
+    bool is_init_{false}; ///< flag to indicate readiness
+  };
+
+} // namespace corsika
+
+#include <corsika/detail/output/BaseOutput.inl>
diff --git a/corsika/output/DummyOutputManager.hpp b/corsika/output/DummyOutputManager.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..fc5cf4647d5daa227fceceda5961bb051a840058
--- /dev/null
+++ b/corsika/output/DummyOutputManager.hpp
@@ -0,0 +1,70 @@
+/*
+ * (c) Copyright 2021 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+#pragma once
+
+namespace corsika {
+
+  /*!
+   * An output manager that does nothing.
+   */
+  class DummyOutputManager final {
+
+  public:
+    /**
+     * Construct an OutputManager instance with a name in a given directory.
+     *
+     * @param name    The name of this output collection.
+     * @param dir     The directory where the output directory will be stored.
+     */
+    DummyOutputManager();
+
+    /**
+     * Handle graceful closure of the outputs upon destruction.
+     */
+    ~DummyOutputManager();
+
+    /**
+     * Register an existing output to this manager.
+     *
+     * @param name    The unique name of this output.
+     * @param args... These are perfect forwarded to the
+     *                constructor of the output.
+     */
+    template <typename TOutput>
+    void add(std::string const& name, TOutput& output);
+
+    /**
+     * Called at the start of each library.
+     *
+     * This iteratively calls startOfLibrary on each registered output.
+     */
+    void startOfLibrary();
+
+    /**
+     * Called at the start of each event/shower.
+     * This iteratively calls startOfEvent on each registered output.
+     */
+    void startOfShower();
+
+    /**
+     * Called at the end of each event/shower.
+     * This iteratively calls endOfEvent on each registered output.
+     */
+    void endOfShower();
+
+    /**
+     * Called at the end of each library.
+     * This iteratively calls endOfLibrary on each registered output.
+     */
+    void endOfLibrary();
+
+  }; // class DummyOutputManager
+
+} // namespace corsika
+
+#include <corsika/detail/output/DummyOutputManager.inl>
diff --git a/corsika/output/NoOutput.hpp b/corsika/output/NoOutput.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..289c19ad720f9f03d621d3b50ac031c40cc794cf
--- /dev/null
+++ b/corsika/output/NoOutput.hpp
@@ -0,0 +1,62 @@
+/*
+ * (c) Copyright 2021 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+#pragma once
+
+#include <corsika/framework/core/ParticleProperties.hpp>
+#include <corsika/framework/core/PhysicalUnits.hpp>
+
+namespace corsika {
+
+  /**
+   * This is the base class for all outputs so that they
+   * can be stored in homogeneous containers.
+   */
+  class NoOutput : public BaseOutput {
+
+  protected:
+    NoOutput() {}
+
+  public:
+    /**
+     * Called at the start of each run.
+     */
+    void startOfLibrary(boost::filesystem::path const&) final override {}
+
+    /**
+     * Called at the start of each event/shower.
+     */
+    void startOfShower() final override {}
+
+    /**
+     * Called at the end of each event/shower.
+     */
+    void endOfShower() final override {}
+
+    /**
+     * Called at the end of each run.
+     */
+    void endOfLibrary() final override {}
+
+    /**
+     * Get the configuration of this output.
+     */
+    YAML::Node getConfig() const { return YAML::Node(); };
+
+    /**
+     * Get any summary information for the entire library.
+     */
+    YAML::Node getSummary() final override { return YAML::Node(); };
+
+  protected:
+    void write(Code const&, units::si::HEPEnergyType const&, units::si::LengthType const&,
+               units::si::LengthType const&) {}
+  };
+
+} // namespace corsika
+
+#include <corsika/detail/output/BaseOutput.inl>
diff --git a/corsika/output/OutputManager.hpp b/corsika/output/OutputManager.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..2582220520454d028b660ca06f5ac93ed8a18b73
--- /dev/null
+++ b/corsika/output/OutputManager.hpp
@@ -0,0 +1,118 @@
+/*
+ * (c) Copyright 2021 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+#pragma once
+
+#include <chrono>
+#include <string>
+#include <boost/filesystem.hpp>
+#include <corsika/output/BaseOutput.hpp>
+#include <corsika/framework/core/Logging.hpp>
+
+namespace corsika {
+
+  /*!
+   * Manages CORSIKA 8 output streams.
+   */
+  class OutputManager final {
+
+    /**
+     * Indicates the current state of this manager.
+     */
+    enum class OutputState {
+      NoInit,
+      LibraryReady,
+      ShowerInProgress,
+      LibraryFinished,
+    };
+
+    OutputState state_{OutputState::NoInit}; ///< The current state of this manager.
+    std::string const name_;                 ///< The name of this simulation file.
+    boost::filesystem::path const root_;     ///< The top-level directory for the output.
+    int count_{0};                           ///< The current ID of this shower.
+    std::chrono::time_point<std::chrono::system_clock> const start_time{
+        std::chrono::system_clock::now()};           ///< The time the manager is created.
+    inline static auto logger{get_logger("output")}; ///< A custom logger.
+    /**
+     * The outputs that have been registered with us.
+     */
+    std::map<std::string, std::reference_wrapper<BaseOutput>> outputs_;
+
+    /**
+     * Write a YAML-node to a file.
+     */
+    void writeNode(YAML::Node const& node, boost::filesystem::path const& path) const;
+
+    /**
+     * Write the top-level config of this simulation.
+     */
+    void writeTopLevelConfig() const;
+
+    /**
+     * Initialize the "registered" output with a given name.
+     */
+    void initOutput(std::string const& name) const;
+
+    /**
+     * Write the top-level summary of this library.
+     */
+    void writeTopLevelSummary() const;
+
+  public:
+    /**
+     * Construct an OutputManager instance with a name in a given directory.
+     *
+     * @param name    The name of this output collection.
+     * @param dir     The directory where the output directory will be stored.
+     */
+    OutputManager(std::string const& name, boost::filesystem::path const& dir);
+
+    /**
+     * Handle graceful closure of the outputs upon destruction.
+     */
+    ~OutputManager();
+
+    /**
+     * Register an existing output to this manager.
+     *
+     * @param name    The unique name of this output.
+     * @param args... These are perfect forwarded to the
+     *                constructor of the output.
+     */
+    template <typename TOutput>
+    void add(std::string const& name, TOutput& output);
+
+    /**
+     * Called at the start of each library.
+     *
+     * This iteratively calls startOfLibrary on each registered output.
+     */
+    void startOfLibrary();
+
+    /**
+     * Called at the start of each event/shower.
+     * This iteratively calls startOfEvent on each registered output.
+     */
+    void startOfShower();
+
+    /**
+     * Called at the end of each event/shower.
+     * This iteratively calls endOfEvent on each registered output.
+     */
+    void endOfShower();
+
+    /**
+     * Called at the end of each library.
+     * This iteratively calls endOfLibrary on each registered output.
+     */
+    void endOfLibrary();
+
+  }; // class OutputManager
+
+} // namespace corsika
+
+#include <corsika/detail/output/OutputManager.inl>
diff --git a/corsika/output/ParquetStreamer.hpp b/corsika/output/ParquetStreamer.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..dcf1f547f72338b45459583a591150487de937c8
--- /dev/null
+++ b/corsika/output/ParquetStreamer.hpp
@@ -0,0 +1,83 @@
+
+/*
+ * (c) Copyright 2021 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+
+#pragma once
+
+#include <string>
+
+// clang-format-off, NOTE: the order of these includes is *important*
+// you will get unhelpful compiler errors about unknown
+// operator definitions if these are reordered
+#include <parquet/stream_writer.h>
+#include <parquet/arrow/schema.h>
+#include <arrow/io/file.h>
+// clang-format-on
+
+namespace corsika {
+
+  /**
+   * This class automates the construction of simple tabular
+   * Parquet files using the parquet::StreamWriter.
+   */
+  class ParquetStreamer {
+
+  public:
+    /**
+     * ParquetStreamer's take no constructor arguments.
+     */
+    ParquetStreamer();
+
+    /**
+     * Initialize the streamer to write to a given file.
+     */
+    void initStreamer(std::string const& filepath);
+
+    /**
+     * Add a field to this streamer.
+     */
+    template <typename... TArgs>
+    void addField(TArgs&&... args);
+
+    /**
+     * Enable compression for this streamer.
+     */
+    void enableCompression(int const level = 3);
+
+    /**
+     * Finalize the streamer construction.
+     */
+    void buildStreamer();
+
+    /**
+     * Finish writing this stream.
+     */
+    void closeStreamer();
+
+    /**
+     * Return a reference to the underlying writer.
+     */
+    std::shared_ptr<parquet::StreamWriter> getWriter();
+
+    /**
+     * @return status of streamer
+     */
+    bool isInit() const { return isInit_; }
+
+  private:
+    bool isInit_ = false;                        ///< flag to handle state of writer
+    parquet::WriterProperties::Builder builder_; ///< The writer properties builder.
+    parquet::schema::NodeVector fields_;         ///< The fields in this file.
+    std::shared_ptr<parquet::schema::GroupNode> schema_;   ///< The schema for this file.
+    std::shared_ptr<arrow::io::FileOutputStream> outfile_; ///< The output file.
+    std::shared_ptr<parquet::StreamWriter> writer_; ///< The stream writer to 'outfile'
+
+  }; // class ParquetStreamer
+} // namespace corsika
+
+#include <corsika/detail/output/ParquetStreamer.inl>
diff --git a/examples/boundary_example.cpp b/examples/boundary_example.cpp
index bca45481a3841f690e80ea5d936063a4ffe91acf..ebb202597bc8f7d09e88b815e89e5f55cf5a3225 100644
--- a/examples/boundary_example.cpp
+++ b/examples/boundary_example.cpp
@@ -14,6 +14,8 @@
 #include <corsika/framework/utility/CorsikaFenv.hpp>
 #include <corsika/framework/core/Logging.hpp>
 
+#include <corsika/output/OutputManager.hpp>
+
 #include <corsika/setup/SetupEnvironment.hpp>
 #include <corsika/setup/SetupStack.hpp>
 #include <corsika/setup/SetupTrajectory.hpp>
@@ -42,6 +44,7 @@
 #include <iostream>
 #include <limits>
 #include <typeinfo>
+#include <fstream>
 
 using namespace corsika;
 using namespace std;
@@ -112,6 +115,8 @@ int main() {
   world->addChild(std::move(target));
   universe.addChild(std::move(world));
 
+  OutputManager output("boundary_outputs");
+
   // setup processes, decays and interactions
   setup::Tracking tracking;
 
@@ -121,7 +126,9 @@ int main() {
 
   ParticleCut cut(50_GeV, true, true);
 
-  TrackWriter trackWriter("boundary_tracks.dat");
+  TrackWriter trackWriter;
+  output.add("tracks", trackWriter); // register TrackWriter
+
   MyBoundaryCrossingProcess<true> boundaryCrossing("crossings.dat");
 
   // assemble all processes into an ordered process list
@@ -164,7 +171,7 @@ int main() {
   }
 
   // define air shower object, run simulation
-  Cascade EAS(env, tracking, sequence, stack);
+  Cascade EAS(env, tracking, sequence, output, stack);
 
   EAS.run();
 
@@ -174,4 +181,6 @@ int main() {
       (cut.getCutEnergy() + cut.getInvEnergy() + cut.getEmEnergy());
   CORSIKA_LOG_INFO("Total energy (GeV): {} relative difference (%): {}", Efinal / 1_GeV,
                    (Efinal / E0 - 1.) * 100);
+
+  output.endOfLibrary();
 }
diff --git a/examples/cascade_example.cpp b/examples/cascade_example.cpp
index b9195515c189d992adaedba6f59cf99496ab44e5..f7758db0a3163521274ee5b0b33e21cc478c4405 100644
--- a/examples/cascade_example.cpp
+++ b/examples/cascade_example.cpp
@@ -11,10 +11,11 @@
 #include <corsika/framework/core/PhysicalUnits.hpp>
 #include <corsika/framework/random/RNGManager.hpp>
 #include <corsika/framework/geometry/Sphere.hpp>
-
 #include <corsika/framework/utility/CorsikaFenv.hpp>
 #include <corsika/framework/core/Logging.hpp>
 
+#include <corsika/output/OutputManager.hpp>
+
 #include <corsika/media/Environment.hpp>
 #include <corsika/media/HomogeneousMedium.hpp>
 #include <corsika/media/NuclearComposition.hpp>
@@ -54,7 +55,7 @@ using namespace std;
 //
 int main() {
 
-  logging::set_level(logging::level::trace);
+  logging::set_level(logging::level::info);
 
   std::cout << "cascade_example" << std::endl;
 
@@ -106,6 +107,8 @@ int main() {
       rootCS, 0_m, 0_m,
       height_atmosphere); // this is the CORSIKA 7 start of atmosphere/universe
 
+  OutputManager output("cascade_outputs");
+
   ShowerAxis const showerAxis{injectionPos, Vector{rootCS, 0_m, 0_m, -100_km}, env};
 
   {
@@ -139,7 +142,9 @@ int main() {
   // cascade with only HE model ==> HE cut
   ParticleCut cut(80_GeV, true, true);
 
-  TrackWriter trackWriter("tracks.dat");
+  TrackWriter trackWriter;
+  output.add("tracks", trackWriter); // register TrackWriter
+
   BetheBlochPDG eLoss{showerAxis};
 
   // assemble all processes into an ordered process list
@@ -147,7 +152,7 @@ int main() {
       make_sequence(stackInspect, sibyll, sibyllNuc, decay, eLoss, cut, trackWriter);
 
   // define air shower object, run simulation
-  Cascade EAS(env, tracking, sequence, stack);
+  Cascade EAS(env, tracking, sequence, output, stack);
 
   EAS.run();
 
@@ -161,4 +166,6 @@ int main() {
   cout << "total dEdX energy (GeV): " << eLoss.getTotal() / 1_GeV << endl
        << "relative difference (%): " << eLoss.getTotal() / E0 * 100 << endl;
   cut.reset();
+
+  output.endOfLibrary();
 }
diff --git a/examples/cascade_proton_example.cpp b/examples/cascade_proton_example.cpp
index 4b24f3b0148045a2db97546c37c6a2916e3c1d1e..a02c914e4ad1d554160d36745e5d00a2ee6d981e 100644
--- a/examples/cascade_proton_example.cpp
+++ b/examples/cascade_proton_example.cpp
@@ -11,10 +11,11 @@
 #include <corsika/framework/core/PhysicalUnits.hpp>
 #include <corsika/framework/random/RNGManager.hpp>
 #include <corsika/framework/geometry/Sphere.hpp>
-
 #include <corsika/framework/utility/CorsikaFenv.hpp>
 #include <corsika/framework/core/Logging.hpp>
 
+#include <corsika/output/OutputManager.hpp>
+
 #include <corsika/media/Environment.hpp>
 #include <corsika/media/HomogeneousMedium.hpp>
 #include <corsika/media/NuclearComposition.hpp>
@@ -66,6 +67,8 @@ int main() {
   // initialize random number sequence(s)
   RNGManager::getInstance().registerRandomStream("cascade");
 
+  OutputManager output("cascade_proton_outputs");
+
   // setup environment, geometry
   using EnvType = setup::Environment;
   EnvType env;
@@ -115,7 +118,7 @@ int main() {
 
   // setup processes, decays and interactions
   setup::Tracking tracking;
-  StackInspector<setup::Stack> stackInspect(1, true, E0);
+  StackInspector<setup::Stack> stackInspect(1000, true, E0);
 
   RNGManager::getInstance().registerRandomStream("sibyll");
   RNGManager::getInstance().registerRandomStream("pythia");
@@ -130,7 +133,9 @@ int main() {
   // HadronicElasticModel::HadronicElasticInteraction
   // hadronicElastic(env);
 
-  TrackWriter trackWriter("tracks.dat");
+  TrackWriter trackWriter;
+  output.add("tracks", trackWriter); // register TrackWriter
+
   ShowerAxis const showerAxis{injectionPos, Vector{rootCS, 0_m, 0_m, -100_km}, env};
   BetheBlochPDG eLoss{showerAxis};
 
@@ -141,7 +146,7 @@ int main() {
   auto sequence = make_sequence(pythia, decay, eLoss, cut, trackWriter, stackInspect);
 
   // define air shower object, run simulation
-  Cascade EAS(env, tracking, sequence, stack);
+  Cascade EAS(env, tracking, sequence, output, stack);
   EAS.run();
 
   cout << "Result: E0=" << E0 / 1_GeV << endl;
@@ -150,4 +155,6 @@ int main() {
       cut.getCutEnergy() + cut.getInvEnergy() + cut.getEmEnergy();
   cout << "total energy (GeV): " << Efinal / 1_GeV << endl
        << "relative difference (%): " << (Efinal / E0 - 1.) * 100 << endl;
+
+  output.endOfLibrary();
 }
diff --git a/examples/em_shower.cpp b/examples/em_shower.cpp
index e4d8b3cc432a1852f96dbababca6a0c024fc037b..9634b18703905b104b7c882139972d36125cc0f4 100644
--- a/examples/em_shower.cpp
+++ b/examples/em_shower.cpp
@@ -18,6 +18,8 @@
 #include <corsika/framework/process/InteractionCounter.hpp>
 #include <corsika/framework/core/Logging.hpp>
 
+#include <corsika/output/OutputManager.hpp>
+
 #include <corsika/media/Environment.hpp>
 #include <corsika/media/LayeredSphericalAtmosphereBuilder.hpp>
 #include <corsika/media/NuclearComposition.hpp>
@@ -66,7 +68,6 @@ using MyExtraEnv = MediumPropertyModel<UniformMagneticField<T>>;
 int main(int argc, char** argv) {
 
   logging::set_level(logging::level::info);
-  corsika_logger->set_pattern("[%n:%^%-8l%$] custom pattern: %v");
 
   if (argc != 2) {
     std::cerr << "usage: em_shower <energy/GeV>" << std::endl;
@@ -137,6 +138,7 @@ int main(int argc, char** argv) {
   std::cout << "shower axis length: " << (showerCore - injectionPos).getNorm() * 1.02
             << std::endl;
 
+  OutputManager output("em_shower_outputs");
   ShowerAxis const showerAxis{injectionPos, (showerCore - injectionPos) * 1.02, env};
 
   // setup processes, decays and interactions
@@ -146,7 +148,8 @@ int main(int argc, char** argv) {
   corsika::proposal::ContinuousProcess emContinuous(env);
   InteractionCounter emCascadeCounted(emCascade);
 
-  TrackWriter trackWriter("tracks.dat");
+  TrackWriter trackWriter;
+  output.add("tracks", trackWriter); // register TrackWriter
 
   // long. profile; columns for gamma, e+, e- still need to be added
   LongitudinalProfile longprof{showerAxis};
@@ -154,12 +157,13 @@ int main(int argc, char** argv) {
   Plane const obsPlane(showerCore, DirectionVector(rootCS, {0., 0., 1.}));
   ObservationPlane observationLevel(obsPlane, DirectionVector(rootCS, {1., 0., 0.}),
                                     "particles.dat");
+  output.add("obsplane", observationLevel);
 
   auto sequence = make_sequence(emCascadeCounted, emContinuous, longprof, cut,
                                 observationLevel, trackWriter);
   // define air shower object, run simulation
   setup::Tracking tracking;
-  Cascade EAS(env, tracking, sequence, stack);
+  Cascade EAS(env, tracking, sequence, output, stack);
 
   // to fix the point of first interaction, uncomment the following two lines:
   //  EAS.setNodes();
@@ -183,4 +187,6 @@ int main(int argc, char** argv) {
   save_hist(hists.labHist(), "inthist_lab_emShower.npz", true);
   save_hist(hists.CMSHist(), "inthist_cms_emShower.npz", true);
   longprof.save("longprof_emShower.txt");
+
+  output.endOfLibrary();
 }
diff --git a/examples/geometry_example.cpp b/examples/geometry_example.cpp
index 7a25708a8d434b8b35e89a8cd23b1e4ad35e0f1f..596e6c1842011d56d8db27dc6e02fe81ab8bdbb9 100644
--- a/examples/geometry_example.cpp
+++ b/examples/geometry_example.cpp
@@ -21,7 +21,6 @@ using namespace corsika;
 int main() {
 
   logging::set_level(logging::level::info);
-  corsika_logger->set_pattern("[%n:%^%-8l%$] custom pattern: %v");
 
   CORSIKA_LOG_INFO("geometry_example");
 
diff --git a/examples/helix_example.cpp b/examples/helix_example.cpp
index cf58531b4170aa5b74a3693033b71c700cde102e..e980931b74ace074bf6d0459cf69e6d158f32b91 100644
--- a/examples/helix_example.cpp
+++ b/examples/helix_example.cpp
@@ -21,7 +21,6 @@ using namespace corsika;
 int main() {
 
   logging::set_level(logging::level::info);
-  corsika_logger->set_pattern("[%n:%^%-8l%$] custom pattern: %v");
 
   CORSIKA_LOG_INFO("helix_example");
 
diff --git a/examples/hybrid_MC.cpp b/examples/hybrid_MC.cpp
index af379ca89bc2a14ccc706e653ca6e3fa25e7d49c..f1602f5ce24a7359d8e1de500369f70d32177b5f 100644
--- a/examples/hybrid_MC.cpp
+++ b/examples/hybrid_MC.cpp
@@ -25,6 +25,8 @@
 #include <corsika/framework/core/Cascade.hpp>
 #include <corsika/framework/geometry/PhysicalGeometry.hpp>
 
+#include <corsika/output/OutputManager.hpp>
+
 #include <corsika/media/Environment.hpp>
 #include <corsika/media/FlatExponential.hpp>
 #include <corsika/media/LayeredSphericalAtmosphereBuilder.hpp>
@@ -86,7 +88,6 @@ using MyExtraEnv = MediumPropertyModel<UniformMagneticField<T>>;
 int main(int argc, char** argv) {
 
   logging::set_level(logging::level::info);
-  corsika_logger->set_pattern("[%n:%^%-8l%$] custom pattern: %v");
 
   CORSIKA_LOG_INFO("hybrid_MC");
 
@@ -171,6 +172,7 @@ int main(int argc, char** argv) {
   std::cout << "shower axis length: " << (showerCore - injectionPos).getNorm() * 1.02
             << std::endl;
 
+  OutputManager output("hybrid_MC_outputs");
   ShowerAxis const showerAxis{injectionPos, (showerCore - injectionPos) * 1.02, env};
 
   // setup processes, decays and interactions
@@ -219,6 +221,7 @@ int main(int argc, char** argv) {
   Plane const obsPlane(showerCore, DirectionVector(rootCS, {0., 0., 1.}));
   ObservationPlane observationLevel(obsPlane, DirectionVector(rootCS, {1., 0., 0.}),
                                     "particles.dat");
+  output.add("obsplane", observationLevel);
 
   corsika::urqmd::UrQMD urqmd_model;
   InteractionCounter urqmdCounted{urqmd_model};
@@ -240,7 +243,7 @@ int main(int argc, char** argv) {
 
   // define air shower object, run simulation
   setup::Tracking tracking;
-  Cascade EAS(env, tracking, sequence, stack);
+  Cascade EAS(env, tracking, sequence, output, stack);
 
   // to fix the point of first interaction, uncomment the following two lines:
   //  EAS.SetNodes();
@@ -272,4 +275,6 @@ int main(int argc, char** argv) {
 
   std::ofstream finish("finished");
   finish << "run completed without error" << std::endl;
+
+  output.endOfLibrary();
 }
diff --git a/examples/vertical_EAS.cpp b/examples/vertical_EAS.cpp
index e5f6455d3f6782cef10d69c128e38534c50a2d8d..928197970e8c27f08b93d561f33aab1defea5796 100644
--- a/examples/vertical_EAS.cpp
+++ b/examples/vertical_EAS.cpp
@@ -25,6 +25,8 @@
 #include <corsika/framework/core/Cascade.hpp>
 #include <corsika/framework/geometry/PhysicalGeometry.hpp>
 
+#include <corsika/output/OutputManager.hpp>
+
 #include <corsika/media/Environment.hpp>
 #include <corsika/media/FlatExponential.hpp>
 #include <corsika/media/HomogeneousMedium.hpp>
@@ -97,7 +99,6 @@ using MyExtraEnv = MediumPropertyModel<UniformMagneticField<T>>;
 
 int main(int argc, char** argv) {
 
-  corsika_logger->set_pattern("[%n:%^%-8l%$] %s:%#: %v");
   logging::set_level(logging::level::info);
 
   CORSIKA_LOG_INFO("vertical_EAS");
@@ -134,6 +135,7 @@ int main(int argc, char** argv) {
       {{Code::Nitrogen, Code::Oxygen},
        {0.7847f, 1.f - 0.7847f}}); // values taken from AIRES manual, Ar removed for now
 
+  builder.addExponentialLayer(1222.6562_g / (1_cm * 1_cm), 994186.38_cm, 2_km);
   builder.addExponentialLayer(1222.6562_g / (1_cm * 1_cm), 994186.38_cm, 4_km);
   builder.addExponentialLayer(1144.9069_g / (1_cm * 1_cm), 878153.55_cm, 10_km);
   builder.addExponentialLayer(1305.5948_g / (1_cm * 1_cm), 636143.04_cm, 40_km);
@@ -141,22 +143,6 @@ int main(int argc, char** argv) {
   builder.addLinearLayer(1e9_cm, 112.8_km + constants::EarthRadius::Mean);
   builder.assemble(env);
 
-  CORSIKA_LOG_DEBUG(
-      "environment setup: universe={}, layer1={}, layer2={}, layer3={}, layer4={}, "
-      "layer5={}",
-      fmt::ptr(env.getUniverse()->getContainingNode(
-          Point(rootCS, {constants::EarthRadius::Mean + 130_km, 0_m, 0_m}))),
-      fmt::ptr(env.getUniverse()->getContainingNode(
-          Point(rootCS, {constants::EarthRadius::Mean + 110_km, 0_m, 0_m}))),
-      fmt::ptr(env.getUniverse()->getContainingNode(
-          Point(rootCS, {constants::EarthRadius::Mean + 50_km, 0_m, 0_m}))),
-      fmt::ptr(env.getUniverse()->getContainingNode(
-          Point(rootCS, {constants::EarthRadius::Mean + 20_km, 0_m, 0_m}))),
-      fmt::ptr(env.getUniverse()->getContainingNode(
-          Point(rootCS, {constants::EarthRadius::Mean + 5_km, 0_m, 0_m}))),
-      fmt::ptr(env.getUniverse()->getContainingNode(
-          Point(rootCS, {constants::EarthRadius::Mean + 2_km, 0_m, 0_m}))));
-
   // pre-setup particle stack
   unsigned short const A = std::stoi(std::string(argv[1]));
   Code beamCode;
@@ -214,6 +200,9 @@ int main(int argc, char** argv) {
 
   ShowerAxis const showerAxis{injectionPos, (showerCore - injectionPos) * 1.5, env};
 
+  // create the output manager that we then register outputs with
+  OutputManager output("vertical_EAS_outputs");
+
   // setup processes, decays and interactions
 
   // corsika::qgsjetII::Interaction qgsjet;
@@ -280,8 +269,6 @@ int main(int argc, char** argv) {
     string const labHist_file = "inthist_lab_verticalEAS_" + to_string(i_shower) + ".npz";
     string const cMSHist_file = "inthist_cms_verticalEAS_" + to_string(i_shower) + ".npz";
     string const longprof_file = "longprof_verticalEAS_" + to_string(i_shower) + ".txt";
-    string const tracks_file = "tracks_" + to_string(i_shower) + ".dat";
-    string const particles_file = "particles_" + to_string(i_shower) + ".dat";
 
     std::cout << std::endl;
     std::cout << "Shower " << i_shower << "/" << number_showers << std::endl;
@@ -289,6 +276,46 @@ int main(int argc, char** argv) {
     // setup particle stack, and add primary particle
     setup::Stack stack;
     stack.clear();
+    unsigned short const A = std::stoi(std::string(argv[1]));
+    Code beamCode;
+    HEPEnergyType mass;
+    unsigned short Z = 0;
+    if (A > 0) {
+      beamCode = Code::Nucleus;
+      Z = std::stoi(std::string(argv[2]));
+      mass = get_nucleus_mass(A, Z);
+    } else {
+      int pdg = std::stoi(std::string(argv[2]));
+      beamCode = convert_from_PDG(PDGCode(pdg));
+      mass = get_mass(beamCode);
+    }
+    HEPEnergyType const E0 = 1_GeV * std::stof(std::string(argv[3]));
+    double theta = 0.;
+    auto const thetaRad = theta / 180. * M_PI;
+
+    auto elab2plab = [](HEPEnergyType Elab, HEPMassType m) {
+      return sqrt((Elab - m) * (Elab + m));
+    };
+    HEPMomentumType P0 = elab2plab(E0, mass);
+    auto momentumComponents = [](double thetaRad, HEPMomentumType ptot) {
+      return std::make_tuple(ptot * sin(thetaRad), 0_eV, -ptot * cos(thetaRad));
+    };
+
+    auto const [px, py, pz] = momentumComponents(thetaRad, P0);
+    auto plab = MomentumVector(rootCS, {px, py, pz});
+    cout << "input particle: " << beamCode << endl;
+    cout << "input angles: theta=" << theta << endl;
+    cout << "input momentum: " << plab.getComponents() / 1_GeV
+         << ", norm = " << plab.getNorm() << endl;
+
+    auto const observationHeight = 0_km + builder.getEarthRadius();
+    auto const injectionHeight = 111.75_km + builder.getEarthRadius();
+    auto const t = (injectionHeight - observationHeight) / cos(thetaRad);
+    Point const showerCore{rootCS, 0_m, 0_m, observationHeight};
+    Point const injectionPos =
+        showerCore + DirectionVector{rootCS, {-sin(thetaRad), 0, cos(thetaRad)}} * t;
+
+    std::cout << "point of injection: " << injectionPos.getCoordinates() << std::endl;
 
     if (A > 1) {
       stack.addParticle(std::make_tuple(beamCode, E0, plab, injectionPos, 0_ns, A, Z));
@@ -308,9 +335,64 @@ int main(int argc, char** argv) {
       }
     }
 
-    TrackWriter trackWriter(tracks_file);
-    ObservationPlane observationLevel(obsPlane, DirectionVector(rootCS, {1., 0., 0.}),
-                                      particles_file);
+    // we make the axis much longer than the inj-core distance since the
+    // profile will go beyond the core, depending on zenith angle
+    std::cout << "shower axis length: " << (showerCore - injectionPos).getNorm() * 1.5
+              << std::endl;
+
+    ShowerAxis const showerAxis{injectionPos, (showerCore - injectionPos) * 1.5, env,
+                                false};
+
+    // setup processes, decays and interactions
+
+    // corsika::qgsjetII::Interaction qgsjet;
+    corsika::sibyll::Interaction sibyll;
+    InteractionCounter sibyllCounted(sibyll);
+
+    corsika::sibyll::NuclearInteraction sibyllNuc(sibyll, env);
+    InteractionCounter sibyllNucCounted(sibyllNuc);
+
+    corsika::pythia8::Decay decayPythia;
+
+    // use sibyll decay routine for decays of particles unknown to pythia
+    corsika::sibyll::Decay decaySibyll{{
+        Code::N1440Plus,
+        Code::N1440MinusBar,
+        Code::N1440_0,
+        Code::N1440_0Bar,
+        Code::N1710Plus,
+        Code::N1710MinusBar,
+        Code::N1710_0,
+        Code::N1710_0Bar,
+
+        Code::Pi1300Plus,
+        Code::Pi1300Minus,
+        Code::Pi1300_0,
+
+        Code::KStar0_1430_0,
+        Code::KStar0_1430_0Bar,
+        Code::KStar0_1430_Plus,
+        Code::KStar0_1430_MinusBar,
+    }};
+
+    decaySibyll.printDecayConfig();
+
+    ParticleCut cut{60_GeV, true, true};
+    // corsika::proposal::Interaction emCascade(env);
+    // corsika::proposal::ContinuousProcess emContinuous(env);
+    // InteractionCounter emCascadeCounted(emCascade);
+    BetheBlochPDG emContinuous(showerAxis);
+
+    OnShellCheck reset_particle_mass(1.e-3, 1.e-1, false);
+    TrackWriter trackWriter;
+    output.add("tracks", trackWriter); // register TrackWriter
+
+    LongitudinalProfile longprof{showerAxis};
+
+    Plane const obsPlane(showerCore, DirectionVector(rootCS, {0., 0., 1.}));
+    ObservationPlane observationLevel(obsPlane, DirectionVector(rootCS, {1., 0., 0.}));
+    // register the observation plane with the output
+    output.add("obsplane", observationLevel);
 
     auto sequence =
         make_sequence(stackInspect, hadronSequence, reset_particle_mass, decaySequence,
@@ -318,7 +400,7 @@ int main(int argc, char** argv) {
 
     // define air shower object, run simulation
     setup::Tracking tracking;
-    Cascade EAS(env, tracking, sequence, stack);
+    Cascade EAS(env, tracking, sequence, output, stack);
 
     // to fix the point of first interaction, uncomment the following two lines:
     //  EAS.forceInteraction();
@@ -326,16 +408,16 @@ int main(int argc, char** argv) {
     EAS.run();
 
     cut.showResults();
-    emContinuous.showResults();
+    // emContinuous.showResults();
     observationLevel.showResults();
     const HEPEnergyType Efinal = cut.getCutEnergy() + cut.getInvEnergy() +
-                                 cut.getEmEnergy() + emContinuous.getEnergyLost() +
+                                 cut.getEmEnergy() + // emContinuous.getEnergyLost() +
                                  observationLevel.getEnergyGround();
     cout << "total cut energy (GeV): " << Efinal / 1_GeV << endl
          << "relative difference (%): " << (Efinal / E0 - 1) * 100 << endl;
     observationLevel.reset();
     cut.reset();
-    emContinuous.reset();
+    // emContinuous.reset();
 
     auto const hists = sibyllCounted.getHistogram() + sibyllNucCounted.getHistogram() +
                        urqmdCounted.getHistogram();
@@ -343,5 +425,7 @@ int main(int argc, char** argv) {
     save_hist(hists.labHist(), labHist_file, true);
     save_hist(hists.CMSHist(), cMSHist_file, true);
     longprof.save(longprof_file);
+
+    output.endOfLibrary();
   }
 }
diff --git a/python/.gitignore b/python/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..7b9e44cc96089e3d033d74eb46965d968bd28d52
--- /dev/null
+++ b/python/.gitignore
@@ -0,0 +1,147 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# ignore any generated output files
+*.npz
+*.dat
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# static files generated from Django application using `collectstatic`
+media
+static
diff --git a/python/Makefile b/python/Makefile
new file mode 100644
index 0000000000000000000000000000000000000000..ccc13257fe572b456857657929c41d8199ad6529
--- /dev/null
+++ b/python/Makefile
@@ -0,0 +1,30 @@
+##
+# ##
+# corsika
+#
+# @file
+
+# find python3
+PYTHON=`which python3`
+
+# our testing targets
+.PHONY: tests flake black isort all
+
+all: mypy isort black flake tests
+
+tests:
+	${PYTHON} -m pytest --cov=corsika tests
+
+flake:
+	${PYTHON} -m flake8 corsika tests
+
+black:
+	${PYTHON} -m black -t py37 corsika tests
+
+isort:
+	${PYTHON} -m isort --atomic corsika tests
+
+mypy:
+	${PYTHON} -m mypy corsika
+
+# end
diff --git a/python/README.md b/python/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..7e35dfe389dde4d372b1e38301c41c42a45c6fa8
--- /dev/null
+++ b/python/README.md
@@ -0,0 +1,8 @@
+# CORSIKA 8 - Python Library
+
+To install this into your global environment using `pip` (not recommended), run
+
+``` shell
+pip install --user git+https://gitlab.ikp.kit.edu/AirShowerPhysics/corsika/-/tree/master/python 
+```
+
diff --git a/python/corsika/__init__.py b/python/corsika/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..078d688098095f06e2a14d2db4c203370993d578
--- /dev/null
+++ b/python/corsika/__init__.py
@@ -0,0 +1,17 @@
+"""
+ A Python interface to CORSIKA 8.
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+
+from . import io
+from .io.library import Library
+
+# all imported objects
+__all__ = ["io", "Library"]
+
+__version__: str = "8.0.0-alpha"
diff --git a/python/corsika/io/__init__.py b/python/corsika/io/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..296572d6dee46a4b95a61b8ef18c549f120a1205
--- /dev/null
+++ b/python/corsika/io/__init__.py
@@ -0,0 +1,15 @@
+"""
+ The 'io' module provides for reading CORSIKA8 output files.
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+
+from .hist import read_hist
+from .library import Library
+
+# all exported objects
+__all__ = ["read_hist", "Library"]
diff --git a/python/corsika/io/hist.py b/python/corsika/io/hist.py
new file mode 100644
index 0000000000000000000000000000000000000000..615db0faec0ec63cff8fe00fc5ebe1d8ce08f2ff
--- /dev/null
+++ b/python/corsika/io/hist.py
@@ -0,0 +1,70 @@
+"""
+ This file supports reading boost_histograms from CORSIKA8.
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+
+import boost_histogram as bh
+import numpy as np
+
+
+def read_hist(filename: str) -> bh.Histogram:
+    """
+    Read a histogram produced with CORSIKA8's `SaveBoostHistogram()` function.
+
+    Parameters
+    ----------
+    filename: str
+        The filename of the .npy file containing the histogram.
+
+    Returns
+    -------
+    hist: bh.Histogram
+        An initialized bh.Histogram instance.
+
+    Throws
+    ------
+    ValueError:
+        If the histogram type is not supported.
+    """
+
+    # load the filenames
+    d = np.load(filename)
+
+    # extract the axis and overflows information
+    axistypes = d["axistypes"].view("c")
+    overflow = d["overflow"]
+    underflow = d["underflow"]
+
+    # this is where we store the axes that we extract from the file.
+    axes = []
+
+    # we now loop over the axes
+    for i, (at, has_overflow, has_underflow) in enumerate(
+        zip(axistypes, overflow, underflow)
+    ):
+
+        # continuous histogram
+        if at == b"c":
+            axes.append(
+                bh.axis.Variable(
+                    d[f"binedges_{i}"], overflow=has_overflow, underflow=has_underflow
+                )
+            )
+        # discrete histogram
+        elif at == b"d":
+            axes.append(bh.axis.IntCategory(d[f"bins_{i}"], growth=(not has_overflow)))
+
+        # and unknown histogram type
+        else:
+            raise ValueError(f"'{at}' is not a valid C8 histogram axistype.")
+
+    # create the histogram and fill it in
+    h = bh.Histogram(*axes)
+    h.view(flow=True)[:] = d["data"]
+
+    return h
diff --git a/python/corsika/io/library.py b/python/corsika/io/library.py
new file mode 100644
index 0000000000000000000000000000000000000000..236fc97ac972a11c0fb366876bd92cae20d6ab18
--- /dev/null
+++ b/python/corsika/io/library.py
@@ -0,0 +1,231 @@
+"""
+ This file allows for reading/working with C8 libraries.
+
+ (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+import logging
+import os
+import os.path as op
+import re
+from typing import Any, Dict, Optional, List
+
+import yaml
+
+from . import outputs
+
+
+class Library(object):
+    """
+    Represents a library ("run") of showers produced by C8.
+    """
+
+    def __init__(self, path: str):
+        """
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing the library.
+
+
+        Raises
+        ------
+        ValueError
+            If `path` does not contain a valid CORSIKA8 library.
+        """
+
+        # check that this is a valid library
+        if not self.__valid_library(path):
+            raise ValueError(f"'{path}' does not contain a valid CORSIKA8 library.")
+
+        # store the top-level path
+        self.path = path
+
+        # load the config and summary files
+        self.config = self.load_config(path)
+        self.summary = self.load_summary(path)
+
+        # build the list of outputs
+        self.__outputs = self.__build_outputs(path)
+
+    @property
+    def names(self) -> List[str]:
+        """
+        Return the list of registered outputs.
+        """
+        return list(self.__outputs.keys())
+
+    @property
+    def modules(self) -> Dict[str, str]:
+        """
+        Return the list of registered outputs.
+        """
+        pass
+
+    def get(self, name: str) -> Optional[outputs.Output]:
+        """
+        Return the output with a given name.
+        """
+        if name in self.__outputs:
+            return self.__outputs[name]
+        else:
+            msg = f"Output with name '{name}' not available in this library."
+            logging.getLogger("corsika").warn(msg)
+            return None
+
+    @staticmethod
+    def load_config(path: str) -> Dict[str, Any]:
+        """
+        Load the top-level config from a given library path.
+
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing the library.
+
+        Returns
+        -------
+        dict:
+            The config as a python dictionary.
+
+        Raises
+        ------
+        FileNotFoundError
+            If the config file cannot be found
+
+        """
+        with open(op.join(path, "config.yaml"), "r") as f:
+            return yaml.load(f, Loader=yaml.Loader)
+
+    @staticmethod
+    def load_summary(path: str) -> Dict[str, Any]:
+        """
+        Load the top-level summary from a given library path.
+
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing the library.
+
+        Returns
+        -------
+        dict:
+            The summary as a python dictionary.
+
+        Raises
+        ------
+        FileNotFoundError
+            If the summary file cannot be found
+
+        """
+        with open(op.join(path, "summary.yaml"), "r") as f:
+            return yaml.load(f, Loader=yaml.Loader)
+
+    @staticmethod
+    def __valid_library(path: str) -> bool:
+        """
+        Check if the library pointed to by 'path' is a valid C8 library.
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing the library.
+
+        Returns
+        -------
+        bool:
+            True if this is a valid C8 library.
+
+        """
+
+        # check that the config file exists
+        if not op.exists(op.join(path, "config.yaml")):
+            return False
+
+        # the config file exists, we load it
+        config = Library.load_config(path)
+
+        # and check that the config's "writer" key is correct
+        return config["creator"] == "CORSIKA8"
+
+    @staticmethod
+    def __build_outputs(path: str) -> Dict[str, outputs.Output]:
+        """
+        Build the outputs contained in this library.
+
+        This will print a warning message if a particular
+        output is invalid but will continue to load additional
+        outputs afterwards.
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing this library.
+
+        Returns
+        -------
+        Dict[str, Output]:
+            A dictionary mapping names to initialized outputs.
+        """
+
+        # get a list of the subdirectories in the library
+        _, dirs, _ = next(os.walk(path))
+
+        # this is the dictionary where we store our components
+        components: Dict[str, Any] = {}
+
+        # loop over the subdirectories
+        for subdir in dirs:
+
+            # read the config file for this output
+            config = Library.load_config(op.join(path, subdir))
+
+            # the name keyword is our unique identifier
+            name = config.get("name")
+
+            # get the "type" keyword to identify this component
+            out_type = config.get("type")
+
+            # if `out_type` was None, this is an invalid output
+            if out_type is None or name is None:
+                msg = (
+                    f"'{subdir}' does not contain a valid config."
+                    "Missing 'type' or 'name' keyword."
+                )
+                logging.getLogger("corsika").warn(msg)
+                continue  # skip to the next output, don't error
+
+            # we now have a valid component type, get the corresponding
+            # type from the proccesses subdirectory
+            try:
+
+                # instantiate the output and store it in our dict
+                component = getattr(outputs, out_type)(op.join(path, subdir))
+
+                # check if the read failed
+                if not component.is_good():
+                    msg = (
+                        f"'{name}' encountered an error while reading. "
+                        "This process will be not be loaded."
+                    )
+                    logging.getLogger("corsika").warn(msg)
+                else:
+                    components[name] = component
+
+            except AttributeError as e:
+                msg = (
+                    f"Unable to instantiate an instance of '{out_type}' "
+                    f"for a process called '{name}'"
+                )
+                logging.getLogger("corsika").warn(msg)
+                logging.getLogger("corsika").warn(e)
+                continue  # skip to the next output, don't error
+
+        # and we are done building - return the constructed outputs
+        return components
diff --git a/python/corsika/io/outputs/__init__.py b/python/corsika/io/outputs/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..fa38acdde3c2dc8dd3cc351c16d27159cecd1efb
--- /dev/null
+++ b/python/corsika/io/outputs/__init__.py
@@ -0,0 +1,14 @@
+"""
+
+ (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+
+from .observation_plane import ObservationPlane
+from .track_writer import TrackWriter
+from .output import Output
+
+__all__ = ["Output", "ObservationPlane", "TrackWriter"]
diff --git a/python/corsika/io/outputs/observation_plane.py b/python/corsika/io/outputs/observation_plane.py
new file mode 100644
index 0000000000000000000000000000000000000000..271d145b4f076f1c7d747d6cc5e05b4a183d3655
--- /dev/null
+++ b/python/corsika/io/outputs/observation_plane.py
@@ -0,0 +1,87 @@
+"""
+ Read data written by ObservationPlane.
+
+ (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+import logging
+import os.path as op
+from typing import Any
+
+import pyarrow.parquet as pq
+
+from .output import Output
+
+
+class ObservationPlane(Output):
+    """
+    Read particle data from an ObservationPlane.
+    """
+
+    def __init__(self, path: str):
+        """
+        Load the particle data into a parquet table.
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing this output.
+        """
+        super().__init__(path)
+
+        # try and load our data
+        try:
+            self.__data = pq.read_table(op.join(path, "particles.parquet"))
+        except Exception as e:
+            logging.getLogger("corsika").warn(
+                f"An error occured loading an ObservationPlane: {e}"
+            )
+
+    def is_good(self) -> bool:
+        """
+        Returns true if this output has been read successfully
+        and has the correct files/state/etc.
+
+        Returns
+        -------
+        bool:
+            True if this is a good output.
+        """
+        return self.__data is not None
+
+    def astype(self, dtype: str = "pandas", **kwargs: Any) -> Any:
+        """
+        Load the particle data from this observation plane.
+
+        All additional keyword arguments are passed to `parquet.read_table`
+
+        Parameters
+        ----------
+        dtype: str
+            The data format to return the data in (i.e. numpy, pandas, etc.)
+
+        Returns
+        -------
+        Any:
+            The return type of this method is determined by `dtype`.
+        """
+        if dtype == "arrow":
+            return self.__data
+        elif dtype == "pandas":
+            return self.__data.to_pandas()
+        else:
+            raise ValueError(
+                (
+                    f"Unknown format '{dtype}' for ObservationPlane. "
+                    "We currently only support ['arrow', 'pandas']."
+                )
+            )
+
+    def __repr__(self) -> str:
+        """
+        Return a string representation of this class.
+        """
+        return f"ObservationPlane('{self.config['name']}')"
diff --git a/python/corsika/io/outputs/output.py b/python/corsika/io/outputs/output.py
new file mode 100644
index 0000000000000000000000000000000000000000..83a4c7928b786b9bad4301bec39b7e03aaf567f8
--- /dev/null
+++ b/python/corsika/io/outputs/output.py
@@ -0,0 +1,165 @@
+"""
+ This file defines the API for all output readers.
+
+ (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+import os.path as op
+from abc import ABC, abstractmethod
+from typing import Any, Dict
+
+import yaml
+
+
+class Output(ABC):
+    """
+    This class defines the abstract interface for all classes
+    that wish to provide reading support for CORSIKA8 outputs.
+    """
+
+    def __init__(self, path: str):
+        """
+        __init__ must load the output files and check
+        that it is valid.
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing this output.
+        """
+        # load and store our path and config
+        self.path = path
+        self.__config = self.load_config(path)
+        self.__summary = self.load_summary(path)
+
+    @abstractmethod
+    def is_good(self) -> bool:
+        """
+        Returns true if this output has been read successfully
+        and has the correct files/state/etc.
+
+        Returns
+        -------
+        bool:
+            True if this is a good output.
+        """
+        pass
+
+    @abstractmethod
+    def astype(self, dtype: str, **kwargs: Any) -> Any:
+        """
+        Return the data for this output in the data format given by 'dtype'
+
+        Parameters
+        ----------
+        dtype: str
+            The data format to return the data in (i.e. numpy, pandas, etc.)
+        *args: Any
+            Additional arguments can be accepted by the output.
+        **kwargs: Any
+            Additional keyword arguments can be accepted by the output.
+
+        Returns
+        -------
+        Any:
+            The return type of this method is determined by `dtype`.
+        """
+        pass
+
+    @property
+    def config(self) -> Dict[str, Any]:
+        """
+        Return the config file for this output.
+
+        Parameters
+        ----------
+
+        Returns
+        -------
+        Dict[str, any]
+            The configuration file for this output.
+        """
+        return self.__config
+
+    @property
+    def summary(self) -> Dict[str, Any]:
+        """
+        Return the summary file for this output.
+
+        Parameters
+        ----------
+
+        Returns
+        -------
+        Dict[str, any]
+            The summary file for this output.
+        """
+        return self.__summary
+
+    @property
+    def data(self) -> Any:
+        """
+        Return the data in its default format.
+
+        We try to use Pandas as the default format for most data.
+
+        Parameters
+        ----------
+
+        Returns
+        -------
+        Any:
+            The data in its default format.
+        """
+        return self.astype()
+
+    @staticmethod
+    def load_config(path: str) -> Dict[str, Any]:
+        """
+        Load the top-level config from a given library path.
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing the library.
+
+        Returns
+        -------
+        dict:
+            The config as a python dictionary.
+
+        Raises
+        ------
+        FileNotFoundError
+            If the config file cannot be found
+
+        """
+        with open(op.join(path, "config.yaml"), "r") as f:
+            return yaml.load(f, Loader=yaml.Loader)
+
+    @staticmethod
+    def load_summary(path: str) -> Dict[str, Any]:
+        """
+        Load the top-level summary from a given library path.
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing the library.
+
+        Returns
+        -------
+        dict:
+            The summary as a python dictionary.
+
+        Raises
+        ------
+        FileNotFoundError
+            If the summary file cannot be found
+
+        """
+        with open(op.join(path, "summary.yaml"), "r") as f:
+            return yaml.load(f, Loader=yaml.Loader)
diff --git a/python/corsika/io/outputs/track_writer.py b/python/corsika/io/outputs/track_writer.py
new file mode 100644
index 0000000000000000000000000000000000000000..0859660d590182578e08ad4beaef5bb9a5c53498
--- /dev/null
+++ b/python/corsika/io/outputs/track_writer.py
@@ -0,0 +1,87 @@
+"""
+ Read data written by TrackWriter
+
+ (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+import logging
+import os.path as op
+from typing import Any
+
+import pyarrow.parquet as pq
+
+from .output import Output
+
+
+class TrackWriter(Output):
+    """
+    Read particle data from a TrackWriter
+    """
+
+    def __init__(self, path: str):
+        """
+        Load the particle data into a parquet table.
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing this output.
+        """
+        super().__init__(path)
+
+        # try and load our data
+        try:
+            self.__data = pq.read_table(op.join(path, "tracks.parquet"))
+        except Exception as e:
+            logging.getLogger("corsika").warn(
+                f"An error occured loading a TrackWriter: {e}"
+            )
+
+    def is_good(self) -> bool:
+        """
+        Returns true if this output has been read successfully
+        and has the correct files/state/etc.
+
+        Returns
+        -------
+        bool:
+            True if this is a good output.
+        """
+        return self.__data is not None
+
+    def astype(self, dtype: str = "pandas", **kwargs: Any) -> Any:
+        """
+        Load the particle data from this track writer.
+
+        All additional keyword arguments are passed to `parquet.read_table`
+
+        Parameters
+        ----------
+        dtype: str
+            The data format to return the data in (i.e. numpy, pandas, etc.)
+
+        Returns
+        -------
+        Any:
+            The return type of this method is determined by `dtype`.
+        """
+        if dtype == "arrow":
+            return self.__data
+        elif dtype == "pandas":
+            return self.__data.to_pandas()
+        else:
+            raise ValueError(
+                (
+                    f"Unknown format '{dtype}' for TrackWriter. "
+                    "We currently only support ['arrow', 'pandas']."
+                )
+            )
+
+    def __repr__(self) -> str:
+        """
+        Return a string representation of this class.
+        """
+        return f"TrackWriter('{self.config['name']}')"
diff --git a/python/setup.cfg b/python/setup.cfg
new file mode 100644
index 0000000000000000000000000000000000000000..f58d04eeb061f000b2d890aa26f542f70f3aa0c0
--- /dev/null
+++ b/python/setup.cfg
@@ -0,0 +1,69 @@
+[flake8]
+# use a slightly longer line to be consistent with black
+max-line-length = 88
+
+# E231 is missing whitespace after a comma
+# this contradicts the black formatting rules
+# and therefore creates spurious errors
+ignore = E231
+
+# we set various directories we want to exclude
+exclude =
+    # Don't bother checking in cache directories
+    __pycache__
+
+
+[isort]
+# use parenthesis for multi-line imports
+use_parentheses = true
+
+
+[mypy]
+# the primary Python version
+python_version = 3.7
+
+# allow returning Any
+# this creates excessive errors when using libraries
+# that don't have MyPy typing support
+warn_return_any = False
+
+# don't allow untyped functions
+disallow_untyped_defs = True
+
+# warn if any part of this config is mispelled
+warn_unused_configs = True
+
+# warn for missing type information
+warn_incomplete_stub = True
+
+# warn us if we don't return from a function explicitly
+warn_no_return = True
+
+# use incremental typing to speed things up
+incremental = True
+
+# show error contexts
+show_error_context = True
+
+# and show the column numbers for errors
+show_column_numbers = True
+
+# ignore missing types for setuptools
+[mypy-setuptools.*]
+ignore_missing_imports = True
+
+# ignore missing types for numpy
+[mypy-numpy.*]
+ignore_missing_imports = True
+
+# ignore missing types for matplotlib
+[mypy-matplotlib.*]
+ignore_missing_imports = True
+
+# ignore missing types for boost_histogram
+[mypy-boost_histogram.*]
+ignore_missing_imports = True
+
+# ignore missing types for pyarow
+[mypy-pyarrow.*]
+ignore_missing_imports = True
diff --git a/python/setup.py b/python/setup.py
new file mode 100644
index 0000000000000000000000000000000000000000..196c2ecbbccbc7e5caafee93be0af1c41746c94f
--- /dev/null
+++ b/python/setup.py
@@ -0,0 +1,51 @@
+from os import path
+from setuptools import setup
+
+# the stereo version
+__version__ = "8.0.0-alpha"
+
+# get the absolute path of this project
+here = path.abspath(path.dirname(__file__))
+
+# Get the long description from the README file
+with open(path.join(here, "README.md"), encoding="utf-8") as f:
+    long_description = f.read()
+
+# the standard setup info
+setup(
+    name="corsika",
+    version=__version__,
+    description="A Python package for working with CORSIKA 8.",
+    long_description=long_description,
+    long_description_content_type="text/markdown",
+    url="https://gitlab.ikp.kit.edu/AirShowerPhysics/corsika",
+    author="CORSIKA 8 Collaboration",
+    author_email="corsika-devel@lists.kit.edu",
+    classifiers=[
+        "Development Status :: 3 - Alpha",
+        "Intended Audience :: Science/Research",
+        "Topic :: Scientific/Engineering :: Physics",
+        "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
+    ],
+    keywords=["cosmic ray", "physics", "air shower", "simulation"],
+    packages=["corsika"],
+    python_requires=">=3.6*, <4",
+    install_requires=["numpy", "pyyaml", "pyarrow", "boost_histogram"],
+    extras_require={
+        "test": [
+            "pytest",
+            "black",
+            "mypy",
+            "isort",
+            "coverage",
+            "pytest-cov",
+            "flake8",
+        ],
+        "pandas": ["pandas"],
+    },
+    scripts=[],
+    project_urls={"code": "https://gitlab.ikp.kit.edu/AirShowerPhysics/corsika"},
+    include_package_data=False,
+)
diff --git a/python/tests/__init__.py b/python/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c7d9b672b4c1bde7cc7725c619c0390a79e682c7
--- /dev/null
+++ b/python/tests/__init__.py
@@ -0,0 +1,56 @@
+"""
+ Tests for `corsika`
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+
+import os
+import os.path as op
+
+__all__ = ["build_directory"]
+
+
+def find_build_directory() -> str:
+    """
+    Return the absolute path to the CORSIKA8 build directory.
+
+    Returns
+    -------
+    str
+        The absolute path to the build directory.
+
+    Raises
+    ------
+    RuntimeError
+        If the build directory cannot be found or it is empty.
+    """
+
+    # check if we are running on Gitlab
+    gitlab_build_dir = os.getenv("CI_BUILDS_DIR")
+
+    # if we are running on Gitlab
+    if gitlab_build_dir is not None:
+        build_dir = op.abspath(gitlab_build_dir)
+    else:  # otherwise, we are running locally
+        build_dir = op.abspath(
+            op.join(
+                op.dirname(__file__),
+                op.pardir,
+                op.pardir,
+                "build",
+            )
+        )
+
+    # check that the build directory contains 'CMakeCache.txt'
+    if not op.exists(op.join(build_dir, "CMakeCache.txt")):
+        raise RuntimeError("Python tests cannot find C8 build directory.")
+
+    return build_dir
+
+
+# find the build_directory once
+build_directory = find_build_directory()
diff --git a/python/tests/io/__init__.py b/python/tests/io/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..0d08be944644953fd08405a4502456e76c1203b9
--- /dev/null
+++ b/python/tests/io/__init__.py
@@ -0,0 +1,9 @@
+"""
+ Tests for `corsika.io`
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
diff --git a/python/tests/io/test_hist.py b/python/tests/io/test_hist.py
new file mode 100644
index 0000000000000000000000000000000000000000..7cf5b38fb7798c81680cf14d108e3b31dd3dcaf1
--- /dev/null
+++ b/python/tests/io/test_hist.py
@@ -0,0 +1,70 @@
+"""
+ Tests for `corsika.io.hist`
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+import os
+import os.path as op
+import subprocess
+
+import corsika
+
+from .. import build_directory
+
+# the directory containing 'testSaveBoostHistogram'
+bindir = op.join(build_directory, "Framework", "Utilities")
+
+
+def generate_hist() -> str:
+    """
+    Generate a test with `testSaveBoostHistogram`.
+
+    Returns
+    -------
+    str
+        The path to the generated histogram.
+    bool
+        If True, this file was regenerated.
+    """
+
+    # we construct the name of the bin
+    bin = op.join(bindir, "testSaveBoostHistogram")
+
+    # check if a histogram already exists
+    if op.exists(op.join(bin, "hist.npz")):
+        return op.join(bin, "hist.npz"), False
+    else:
+        # run the program - this generates "hist.npz" in the CWD
+        subprocess.call(bin)
+
+        return op.join(os.getcwd(), "hist.npz"), True
+
+
+def test_corsika_io() -> None:
+    """
+    Test I can corsika.io without a further import.
+    """
+    corsika.io.read_hist
+
+
+def test_corsika_read_hist() -> None:
+    """
+    Check that I can read in the test histograms with `read_hist`.
+    """
+
+    # generate a test histogram
+    filename, delete = generate_hist()
+
+    # and try and read in a histogram
+    h = corsika.io.read_hist(filename)
+
+    # and delete the generated histogram
+    if delete:
+        os.remove(filename)
+
+    # and check that it isn't empty
+    assert not h.empty()
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 3a12d7cc7401177052d3374be42c583073b3ae20..b8f1e709815fa0d41f9628482981cc4d879caec9 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -3,3 +3,4 @@ add_subdirectory (framework)
 add_subdirectory (media)
 add_subdirectory (stack)
 add_subdirectory (modules)
+add_subdirectory (output)
diff --git a/tests/framework/testCascade.cpp b/tests/framework/testCascade.cpp
index 3e930911b095699bd09a10813c38c167c56b5dcc..44d277bae1bf03a7fdfe9fa12eef3ae2c2197f95 100644
--- a/tests/framework/testCascade.cpp
+++ b/tests/framework/testCascade.cpp
@@ -24,6 +24,8 @@
 #include <corsika/media/HomogeneousMedium.hpp>
 #include <corsika/media/NuclearComposition.hpp>
 
+#include <corsika/output/DummyOutputManager.hpp>
+
 #include <SetupTestTrajectory.hpp>
 
 #include <catch2/catch.hpp>
@@ -163,8 +165,10 @@ TEST_CASE("Cascade", "[Cascade]") {
       Point(rootCS, {0_m, 0_m, 10_km}), 0_ns));
 
   DummyTracking tracking;
-  Cascade<DummyTracking, decltype(sequence), TestCascadeStack, TestCascadeStackView> EAS(
-      env, tracking, sequence, stack);
+  DummyOutputManager output;
+  Cascade<DummyTracking, decltype(sequence), DummyOutputManager, TestCascadeStack,
+          TestCascadeStackView>
+      EAS(env, tracking, sequence, output, stack);
 
   SECTION("full cascade") {
     EAS.run();
diff --git a/tests/modules/testObservationPlane.cpp b/tests/modules/testObservationPlane.cpp
index 90a12288bd0b24c4c3021adaf66630fd1265d688..62983a2e08f49eef546e017b104cb104ead3de41 100644
--- a/tests/modules/testObservationPlane.cpp
+++ b/tests/modules/testObservationPlane.cpp
@@ -17,16 +17,17 @@
 #include <corsika/framework/core/ParticleProperties.hpp>
 #include <corsika/framework/core/PhysicalUnits.hpp>
 
+#include <corsika/output/NoOutput.hpp>
+
 #include <SetupTestEnvironment.hpp>
 #include <SetupTestStack.hpp>
 #include <SetupTestTrajectory.hpp>
 
 using namespace corsika;
 
-TEST_CASE("ObservationPlane", "[proccesses][observation_plane]") {
+TEST_CASE("ObservationPlane", "interface") {
 
   logging::set_level(logging::level::trace);
-  corsika_logger->set_pattern("[%n:%^%-8l%$]: %v");
 
   auto [env, csPtr, nodePtr] = setup::testing::setup_environment(Code::Oxygen);
   auto const& cs = *csPtr;
@@ -54,23 +55,38 @@ TEST_CASE("ObservationPlane", "[proccesses][observation_plane]") {
   SECTION("horizontal plane") {
 
     Plane const obsPlane(Point(cs, {10_m, 0_m, 0_m}), DirectionVector(cs, {1., 0., 0.}));
-    ObservationPlane obs(obsPlane, DirectionVector(cs, {0., 1., 0.}), "particles.dat",
-                         true);
+    ObservationPlane<NoOutput> obs(obsPlane, DirectionVector(cs, {0., 1., 0.}));
 
     LengthType const length = obs.getMaxStepLength(particle, no_used_track);
     ProcessReturn const ret = obs.doContinuous(particle, no_used_track, true);
 
     CHECK(length / 10_m == Approx(1).margin(1e-4));
     CHECK(ret == ProcessReturn::ParticleAbsorbed);
+
+    // particle does not reach plane:
+    {
+      setup::Trajectory no_hit_track =
+          setup::testing::make_track<setup::Trajectory>(line, 1_nm / constants::c);
+      LengthType const no_hit = obs.getMaxStepLength(particle, no_hit_track);
+      CHECK(no_hit == std::numeric_limits<double>::infinity() * 1_m);
+    }
+
+    // particle past plane:
+    {
+      particle.setPosition({cs, {0_m, 0_m, -1_m}});
+      setup::Trajectory no_hit_track =
+          setup::testing::make_track<setup::Trajectory>(line, 1_nm / constants::c);
+      LengthType const no_hit = obs.getMaxStepLength(particle, no_hit_track);
+      CHECK(no_hit == std::numeric_limits<double>::infinity() * 1_m);
+    }
   }
 
   SECTION("transparent plane") {
     Plane const obsPlane(Point(cs, {1_m, 0_m, 0_m}), DirectionVector(cs, {1., 0., 0.}));
-    ObservationPlane obs(obsPlane, DirectionVector(cs, {0., 0., 1.}), "particles.dat",
-                         false);
+    ObservationPlane<NoOutput> obs(obsPlane, DirectionVector(cs, {0., 0., 1.}), false);
 
     LengthType const length = obs.getMaxStepLength(particle, no_used_track);
-    ProcessReturn const ret = obs.doContinuous(particle, no_used_track, true);
+    ProcessReturn const ret = obs.doContinuous(particle, no_used_track, false);
 
     CHECK(length / 1_m == Approx(1).margin(1e-4));
     CHECK(ret == ProcessReturn::Ok);
@@ -85,8 +101,7 @@ TEST_CASE("ObservationPlane", "[proccesses][observation_plane]") {
 
     Plane const obsPlane(Point(cs, {10_m, 5_m, 5_m}),
                          DirectionVector(cs, {1, 0.1, -0.05}));
-    ObservationPlane obs(obsPlane, DirectionVector(cs, {0., 1., 0.}), "particles.dat",
-                         true);
+    ObservationPlane<NoOutput> obs(obsPlane, DirectionVector(cs, {0., 1., 0.}));
 
     LengthType const length = obs.getMaxStepLength(particle, no_used_track);
     ProcessReturn const ret = obs.doContinuous(particle, no_used_track, true);
@@ -94,4 +109,11 @@ TEST_CASE("ObservationPlane", "[proccesses][observation_plane]") {
     CHECK(length / 10_m == Approx(1.1375).margin(1e-4));
     CHECK(ret == ProcessReturn::ParticleAbsorbed);
   }
+
+  SECTION("output") {
+    Plane const obsPlane(Point(cs, {1_m, 0_m, 0_m}), DirectionVector(cs, {1., 0., 0.}));
+    ObservationPlane<NoOutput> obs(obsPlane, DirectionVector(cs, {0., 0., 1.}), false);
+    auto const cfg = obs.getConfig();
+    CHECK(cfg["type"]);
+  }
 }
diff --git a/tests/output/CMakeLists.txt b/tests/output/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..98a43b4c218bf8d9e21bb7b9e56ac1275cbc9b0e
--- /dev/null
+++ b/tests/output/CMakeLists.txt
@@ -0,0 +1,16 @@
+set (test_output_sources
+  TestMain.cpp
+  testOutputManager.cpp
+  testDummyOutputManager.cpp
+  testParquetStreamer.cpp
+  testWriterObservationPlane.cpp
+  testWriterTracks.cpp
+  )
+
+CORSIKA_ADD_TEST (testOutput SOURCES ${test_output_sources})
+
+target_compile_definitions (
+  testOutput
+  PRIVATE
+  REFDATADIR="${CMAKE_CURRENT_SOURCE_DIR}"
+)
diff --git a/tests/output/TestMain.cpp b/tests/output/TestMain.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..51532584b8e03b35d79301543ac8f80b598ba544
--- /dev/null
+++ b/tests/output/TestMain.cpp
@@ -0,0 +1,11 @@
+/*
+ * (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+
+#define CATCH_CONFIG_MAIN // This tells Catch to provide a main() - only do this in one
+                          // cpp file
+#include <catch2/catch.hpp>
diff --git a/tests/output/testDummyOutputManager.cpp b/tests/output/testDummyOutputManager.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..cc571832f601c1e9814ca25e1de9d4db152d1afc
--- /dev/null
+++ b/tests/output/testDummyOutputManager.cpp
@@ -0,0 +1,32 @@
+/*
+ * (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+
+#include <catch2/catch.hpp>
+
+#include <corsika/output/DummyOutputManager.hpp>
+#include <corsika/framework/core/Logging.hpp>
+
+using namespace corsika;
+
+class DummyOutput {};
+
+TEST_CASE("DummyOutputManager", "interface") {
+
+  logging::set_level(logging::level::info);
+
+  // output manager performs nothing, no action, just interface
+  DummyOutputManager output;
+
+  DummyOutput test;
+  output.add("test", test);
+
+  output.startOfLibrary();
+  output.startOfShower();
+  output.endOfShower();
+  output.endOfLibrary();
+}
diff --git a/tests/output/testOutputManager.cpp b/tests/output/testOutputManager.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..58f90589c2206ed1e01607cd216a6b76c53a20c9
--- /dev/null
+++ b/tests/output/testOutputManager.cpp
@@ -0,0 +1,184 @@
+/*
+ * (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+
+#include <catch2/catch.hpp>
+
+#include <boost/filesystem.hpp>
+
+#include <corsika/framework/core/Logging.hpp>
+
+#include <corsika/output/OutputManager.hpp>
+#include <corsika/output/NoOutput.hpp>
+
+using namespace corsika;
+
+struct DummyNoOutput : public NoOutput {
+  void check() {
+    NoOutput::startOfLibrary("./");
+    NoOutput::startOfShower();
+    NoOutput::endOfShower();
+    NoOutput::endOfLibrary();
+    NoOutput::getConfig();
+    NoOutput::getSummary();
+  }
+  void checkWrite() { NoOutput::write(Code::Unknown, 1_eV, 1_m, 1_m); }
+};
+
+struct DummyOutput : public BaseOutput {
+
+  mutable bool isConfig_ = false;
+  mutable bool isSummary_ = false;
+  bool startLibrary_ = false;
+  bool startShower_ = false;
+  bool endLibrary_ = false;
+  bool endShower_ = false;
+
+  void startOfLibrary(boost::filesystem::path const&) { startLibrary_ = true; }
+
+  void startOfShower() {
+    BaseOutput::startOfShower();
+    startShower_ = true;
+  }
+
+  void endOfShower() { endShower_ = true; }
+
+  void endOfLibrary() { endLibrary_ = true; }
+
+  YAML::Node getConfig() const {
+    isConfig_ = true;
+    return YAML::Node();
+  }
+
+  YAML::Node getSummary() {
+    isSummary_ = true;
+    return BaseOutput::getSummary();
+  }
+};
+
+TEST_CASE("OutputManager") {
+
+  logging::set_level(logging::level::info);
+
+  SECTION("standard") {
+
+    // preparation
+    if (boost::filesystem::exists("./out_test")) {
+      boost::filesystem::remove_all("./out_test");
+    }
+
+    // output manager performs nothing, no action, just interface
+    OutputManager output("check", "./out_test");
+
+    CHECK(boost::filesystem::is_directory("./out_test/check"));
+
+    DummyOutput test;
+    output.add("test", test);
+
+    CHECK_THROWS(output.add(
+        "test",
+        test)); // should emit warning which cannot be catched, but no action or failure
+
+    CHECK(test.isConfig_);
+    test.isConfig_ = false;
+
+    output.startOfLibrary();
+    CHECK(test.startLibrary_);
+    test.startLibrary_ = false;
+
+    output.startOfShower();
+    CHECK(test.startShower_);
+    test.startShower_ = false;
+
+    output.endOfShower();
+    CHECK(test.endShower_);
+    test.endShower_ = false;
+
+    output.endOfLibrary();
+    CHECK(test.endLibrary_);
+    CHECK(test.isSummary_);
+    test.isSummary_ = false;
+    test.endLibrary_ = false;
+
+    CHECK(boost::filesystem::exists("./out_test/check/test/summary.yaml"));
+  }
+
+  SECTION("auto-write") {
+
+    // preparation
+    if (boost::filesystem::exists("./out_test")) {
+      boost::filesystem::remove_all("./out_test");
+    }
+
+    // output manager performs nothing, no action, just interface
+    OutputManager* output = new OutputManager("check", "./out_test");
+
+    CHECK(boost::filesystem::is_directory("./out_test/check"));
+
+    DummyOutput test;
+    output->add("test", test);
+    output->startOfLibrary();
+    output->startOfShower();
+
+    // check support for closing automatically
+    delete output;
+    output = 0;
+
+    CHECK(boost::filesystem::exists("./out_test/check/test/summary.yaml"));
+  }
+
+  SECTION("failures") {
+
+    logging::set_level(logging::level::info);
+
+    // preparation
+    if (boost::filesystem::exists("./out_test")) {
+      boost::filesystem::remove_all("./out_test");
+    }
+
+    // output manager performs nothing, no action, just interface
+    OutputManager output("check", "./out_test");
+    CHECK_THROWS(new OutputManager("check", "./out_test"));
+
+    // CHECK_THROWS(output.startOfShower());
+    // CHECK_THROWS(output.endOfShower());
+    CHECK_THROWS(output.endOfLibrary());
+
+    output.startOfLibrary();
+
+    CHECK_THROWS(output.startOfLibrary());
+    // CHECK_THROWS(output.endOfShower());
+    // CHECK_THROWS(output.endOfLibrary());
+
+    output.startOfShower();
+
+    CHECK_THROWS(output.startOfLibrary());
+    // CHECK_THROWS(output.startOfShower());
+    // CHECK_THROWS(output.endOfLibrary());
+
+    output.endOfShower();
+
+    CHECK_THROWS(output.startOfLibrary());
+    // CHECK_THROWS(output.startOfShower());
+    // CHECK_THROWS(output.endOfShower());
+
+    output.endOfLibrary();
+
+    // CHECK_THROWS(output.endOfShower());
+    // CHECK_THROWS(output.startOfShower());
+    // CHECK_THROWS(output.endOfLibrary());
+  }
+
+  SECTION("NoOutput") {
+    // this is one of the classes where testing is a bit useless, but we can at least make
+    // sure the interface exists.
+    DummyNoOutput nothing;
+
+    nothing.check();
+    nothing.checkWrite();
+  }
+}
\ No newline at end of file
diff --git a/tests/output/testParquetStreamer.cpp b/tests/output/testParquetStreamer.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..387e5cade02d9e9cf1f1c1532cc1594bffb65d4a
--- /dev/null
+++ b/tests/output/testParquetStreamer.cpp
@@ -0,0 +1,56 @@
+/*
+ * (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+
+#include <catch2/catch.hpp>
+
+#include <boost/filesystem.hpp>
+
+#include <corsika/output/ParquetStreamer.hpp>
+#include <corsika/framework/core/Logging.hpp>
+
+using namespace corsika;
+
+TEST_CASE("ParquetStreamer") {
+
+  logging::set_level(logging::level::info);
+
+  SECTION("standard") {
+
+    // preparation
+    if (boost::filesystem::exists("./parquet_test.parquet")) {
+      boost::filesystem::remove_all("./parquet_test.parquet");
+    }
+
+    ParquetStreamer test;
+    CHECK_FALSE(test.isInit());
+    CHECK_THROWS(test.getWriter());
+    test.initStreamer("./parquet_test.parquet");
+
+    test.addField("testint", parquet::Repetition::REQUIRED, parquet::Type::INT32,
+                  parquet::ConvertedType::INT_32);
+    test.addField("testfloat", parquet::Repetition::REQUIRED, parquet::Type::FLOAT,
+                  parquet::ConvertedType::NONE);
+
+    test.enableCompression(1);
+
+    test.buildStreamer();
+    CHECK(test.isInit());
+
+    int testint = 1;
+    double testfloat = 2.0;
+
+    std::shared_ptr<parquet::StreamWriter> writer = test.getWriter();
+    (*writer) << static_cast<int>(testint) << static_cast<int>(testint)
+              << static_cast<float>(testfloat) << parquet::EndRow;
+
+    test.closeStreamer();
+    CHECK_THROWS(test.getWriter());
+    CHECK_FALSE(test.isInit());
+    CHECK(boost::filesystem::exists("./parquet_test.parquet"));
+  }
+}
\ No newline at end of file
diff --git a/tests/output/testWriterObservationPlane.cpp b/tests/output/testWriterObservationPlane.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..4b780a2b4bbcde53d531f4a6a113d4b2a7087f3a
--- /dev/null
+++ b/tests/output/testWriterObservationPlane.cpp
@@ -0,0 +1,50 @@
+/*
+ * (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+
+#include <catch2/catch.hpp>
+
+#include <boost/filesystem.hpp>
+
+#include <corsika/modules/writers/ObservationPlaneWriterParquet.hpp>
+
+#include <corsika/framework/core/Logging.hpp>
+#include <corsika/framework/geometry/QuantityVector.hpp>
+
+using namespace corsika;
+
+struct TestWriterPlane : public ObservationPlaneWriterParquet {
+
+  YAML::Node getConfig() const { return YAML::Node(); }
+
+  void checkWrite() {
+    ObservationPlaneWriterParquet::write(Code::Unknown, 1_eV, 2_m, 3_m);
+  }
+};
+
+TEST_CASE("ObservationPlaneWriterParquet") {
+
+  logging::set_level(logging::level::info);
+
+  SECTION("standard") {
+
+    // preparation
+    if (boost::filesystem::exists("./output_dir")) {
+      boost::filesystem::remove_all("./output_dir");
+    }
+    boost::filesystem::create_directory("./output_dir");
+
+    TestWriterPlane test;
+    test.startOfLibrary("./output_dir");
+    test.startOfShower();
+    test.checkWrite();
+    test.endOfShower();
+    test.endOfLibrary();
+
+    CHECK(boost::filesystem::exists("./output_dir/particles.parquet"));
+  }
+}
diff --git a/tests/output/testWriterTracks.cpp b/tests/output/testWriterTracks.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..38cf0cf5985a956aaef03b589b93a1b6b9f9b260
--- /dev/null
+++ b/tests/output/testWriterTracks.cpp
@@ -0,0 +1,49 @@
+/*
+ * (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu
+ *
+ * This software is distributed under the terms of the GNU General Public
+ * Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ * the license.
+ */
+
+#include <catch2/catch.hpp>
+
+#include <boost/filesystem.hpp>
+
+#include <corsika/modules/writers/TrackWriterParquet.hpp>
+
+#include <corsika/framework/core/Logging.hpp>
+
+using namespace corsika;
+
+struct TestWriterTrack : public TrackWriterParquet {
+
+  YAML::Node getConfig() const { return YAML::Node(); }
+
+  void checkWrite() {
+    TrackWriterParquet::write(Code::Unknown, 1_eV, {2_m, 3_m, 4_m}, {5_m, 6_m, 7_m});
+  }
+};
+
+TEST_CASE("TrackWriterParquet") {
+
+  logging::set_level(logging::level::info);
+
+  SECTION("standard") {
+
+    // preparation
+    if (boost::filesystem::exists("./output_dir_tracks")) {
+      boost::filesystem::remove_all("./output_dir_tracks");
+    }
+    boost::filesystem::create_directory("./output_dir_tracks");
+
+    TestWriterTrack test;
+    test.startOfLibrary("./output_dir_tracks");
+    test.startOfShower();
+    test.checkWrite();
+    test.endOfShower();
+    test.endOfLibrary();
+
+    CHECK(boost::filesystem::exists("./output_dir_tracks/tracks.parquet"));
+  }
+}