diff --git a/src/main/java/org/scijava/io/AbstractIOPlugin.java b/src/main/java/org/scijava/io/AbstractIOPlugin.java
index 6ccd92f2c..9d314f8a3 100644
--- a/src/main/java/org/scijava/io/AbstractIOPlugin.java
+++ b/src/main/java/org/scijava/io/AbstractIOPlugin.java
@@ -29,15 +29,50 @@
 
 package org.scijava.io;
 
+import org.scijava.io.location.Location;
+import org.scijava.io.location.LocationService;
 import org.scijava.plugin.AbstractHandlerPlugin;
+import org.scijava.plugin.Parameter;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
 
 /**
  * Abstract base class for {@link IOPlugin}s.
  * 
  * @author Curtis Rueden
  */
-public abstract class AbstractIOPlugin<D> extends AbstractHandlerPlugin<String>
-	implements IOPlugin<D>
+public abstract class AbstractIOPlugin<D> extends
+	AbstractHandlerPlugin<Location> implements IOPlugin<D>
 {
-	// NB: No implementation needed.
+
+	@Parameter
+	private LocationService locationService;
+
+	@Override
+	public boolean supportsOpen(final String source) {
+		try {
+			return supportsOpen(locationService.resolve(source));
+		} catch (URISyntaxException e) {
+			return false;
+		}
+	}
+
+	@Override
+	public boolean supportsSave(final String destination) {
+		try {
+			return supportsSave(locationService.resolve(destination));
+		} catch (URISyntaxException e) {
+			return false;
+		}
+	}
+
+	@Override
+	public void save(final D data, final String destination) throws IOException {
+		try {
+			save(data, locationService.resolve(destination));
+		} catch (URISyntaxException e) {
+			throw new IOException(e);
+		}
+	}
 }
diff --git a/src/main/java/org/scijava/io/DefaultIOService.java b/src/main/java/org/scijava/io/DefaultIOService.java
index c71896453..bfdaf35a2 100644
--- a/src/main/java/org/scijava/io/DefaultIOService.java
+++ b/src/main/java/org/scijava/io/DefaultIOService.java
@@ -30,10 +30,13 @@
 package org.scijava.io;
 
 import java.io.IOException;
+import java.net.URISyntaxException;
 
 import org.scijava.event.EventService;
 import org.scijava.io.event.DataOpenedEvent;
 import org.scijava.io.event.DataSavedEvent;
+import org.scijava.io.location.Location;
+import org.scijava.io.location.LocationService;
 import org.scijava.log.LogService;
 import org.scijava.plugin.AbstractHandlerService;
 import org.scijava.plugin.Parameter;
@@ -47,7 +50,7 @@
  */
 @Plugin(type = Service.class)
 public final class DefaultIOService
-	extends AbstractHandlerService<String, IOPlugin<?>> implements IOService
+	extends AbstractHandlerService<Location, IOPlugin<?>> implements IOService
 {
 
 	@Parameter
@@ -56,10 +59,31 @@ public final class DefaultIOService
 	@Parameter
 	private EventService eventService;
 
-	// -- IOService methods --
+	@Parameter
+	private LocationService locationService;
 
 	@Override
 	public Object open(final String source) throws IOException {
+		try {
+			return open(locationService.resolve(source));
+		} catch (URISyntaxException e) {
+			throw new IOException(e);
+		}
+	}
+
+	@Override
+	public void save(final Object data, final String destination)
+			throws IOException
+	{
+		try {
+			save(data, locationService.resolve(destination));
+		} catch (URISyntaxException e) {
+			throw new IOException(e);
+		}
+	}
+
+	@Override
+	public Object open(final Location source) throws IOException {
 		final IOPlugin<?> opener = getOpener(source);
 		if (opener == null) {
 			log.error("No opener IOPlugin found for " + source + ".");
@@ -77,7 +101,7 @@ public Object open(final String source) throws IOException {
 	}
 
 	@Override
-	public void save(final Object data, final String destination)
+	public void save(final Object data, final Location destination)
 		throws IOException
 	{
 		final IOPlugin<Object> saver = getSaver(data, destination);
diff --git a/src/main/java/org/scijava/io/DefaultRecentFileService.java b/src/main/java/org/scijava/io/DefaultRecentFileService.java
index 28a3804ef..a15b172b5 100644
--- a/src/main/java/org/scijava/io/DefaultRecentFileService.java
+++ b/src/main/java/org/scijava/io/DefaultRecentFileService.java
@@ -41,6 +41,8 @@
 import org.scijava.event.EventHandler;
 import org.scijava.event.EventService;
 import org.scijava.io.event.IOEvent;
+import org.scijava.io.location.FileLocation;
+import org.scijava.io.location.Location;
 import org.scijava.menu.MenuConstants;
 import org.scijava.module.ModuleInfo;
 import org.scijava.module.ModuleService;
@@ -181,7 +183,10 @@ public void dispose() {
 
 	@EventHandler
 	protected void onEvent(final IOEvent event) {
-		add(event.getDescriptor());
+		final Location loc = event.getLocation();
+		if (!(loc instanceof FileLocation)) return;
+		final FileLocation fileLoc = (FileLocation) loc;
+		add(fileLoc.getFile().getPath());
 	}
 
 	// -- Helper methods --
diff --git a/src/main/java/org/scijava/io/IOPlugin.java b/src/main/java/org/scijava/io/IOPlugin.java
index 86bcee5b2..298cf6252 100644
--- a/src/main/java/org/scijava/io/IOPlugin.java
+++ b/src/main/java/org/scijava/io/IOPlugin.java
@@ -31,6 +31,8 @@
 
 import java.io.IOException;
 
+import org.scijava.io.location.FileLocation;
+import org.scijava.io.location.Location;
 import org.scijava.plugin.HandlerPlugin;
 import org.scijava.plugin.Plugin;
 
@@ -48,7 +50,7 @@
  * @see Plugin
  * @see IOService
  */
-public interface IOPlugin<D> extends HandlerPlugin<String> {
+public interface IOPlugin<D> extends HandlerPlugin<Location> {
 
 	/** The type of data opened and/or saved by the plugin. */
 	Class<D> getDataType();
@@ -56,44 +58,66 @@ public interface IOPlugin<D> extends HandlerPlugin<String> {
 	/** Checks whether the I/O plugin can open data from the given source. */
 	@SuppressWarnings("unused")
 	default boolean supportsOpen(final String source) {
+		return supportsOpen(new FileLocation(source));
+	}
+
+	/** Checks whether the I/O plugin can open data from the given location. */
+	default boolean supportsOpen(Location source) {
 		return false;
 	}
 
 	/** Checks whether the I/O plugin can save data to the given destination. */
 	@SuppressWarnings("unused")
 	default boolean supportsSave(final String destination) {
+		return supportsSave(new FileLocation(destination));
+	}
+
+	/** Checks whether the I/O plugin can save data to the given location. */
+	default boolean supportsSave(Location destination) {
 		return false;
 	}
 
 	/**
 	 * Checks whether the I/O plugin can save the given data to the specified
-	 * destination.
+	 * location.
 	 */
 	default boolean supportsSave(final Object data, final String destination) {
 		return supportsSave(destination) && getDataType().isInstance(data);
 	}
 
+	default boolean supportsSave(Object data, Location destination) {
+		return supportsSave(destination) && getDataType().isInstance(data);
+	}
+
 	/** Opens data from the given source. */
 	@SuppressWarnings("unused")
 	default D open(final String source) throws IOException {
 		throw new UnsupportedOperationException();
 	}
 
+	/** Opens data from the given location. */
+	default D open(Location source) throws IOException {
+		throw new UnsupportedOperationException();
+	}
 	/** Saves the given data to the specified destination. */
 	@SuppressWarnings("unused")
 	default void save(final D data, final String destination) throws IOException {
+		save(data, new FileLocation(destination));
+	}
+
+	/** Saves the given data to the specified location. */
+	default void save(D data, Location destination) throws IOException {
 		throw new UnsupportedOperationException();
 	}
 
 	// -- Typed methods --
 
-	@Override
 	default boolean supports(final String descriptor) {
 		return supportsOpen(descriptor) || supportsSave(descriptor);
 	}
 
 	@Override
-	default Class<String> getType() {
-		return String.class;
+	default Class<Location> getType() {
+		return Location.class;
 	}
 }
diff --git a/src/main/java/org/scijava/io/IOService.java b/src/main/java/org/scijava/io/IOService.java
index f31ffdf64..4e49f4aee 100644
--- a/src/main/java/org/scijava/io/IOService.java
+++ b/src/main/java/org/scijava/io/IOService.java
@@ -31,6 +31,8 @@
 
 import java.io.IOException;
 
+import org.scijava.io.location.FileLocation;
+import org.scijava.io.location.Location;
 import org.scijava.plugin.HandlerService;
 import org.scijava.service.SciJavaService;
 
@@ -39,15 +41,23 @@
  * 
  * @author Curtis Rueden
  */
-public interface IOService extends HandlerService<String, IOPlugin<?>>,
+public interface IOService extends HandlerService<Location, IOPlugin<?>>,
 	SciJavaService
 {
 
 	/**
 	 * Gets the most appropriate {@link IOPlugin} for opening data from the given
-	 * source.
+	 * location.
 	 */
 	default IOPlugin<?> getOpener(final String source) {
+		return getOpener(new FileLocation(source));
+	}
+
+	/**
+	 * Gets the most appropriate {@link IOPlugin} for opening data from the given
+	 * location.
+	 */
+	default IOPlugin<?> getOpener(Location source) {
 		for (final IOPlugin<?> handler : getInstances()) {
 			if (handler.supportsOpen(source)) return handler;
 		}
@@ -56,9 +66,17 @@ default IOPlugin<?> getOpener(final String source) {
 
 	/**
 	 * Gets the most appropriate {@link IOPlugin} for saving data to the given
-	 * destination.
+	 * location.
 	 */
 	default <D> IOPlugin<D> getSaver(final D data, final String destination) {
+		return getSaver(data, new FileLocation(destination));
+	}
+
+	/**
+	 * Gets the most appropriate {@link IOPlugin} for saving data to the given
+	 * location.
+	 */
+	default <D> IOPlugin<D> getSaver(D data, Location destination) {
 		for (final IOPlugin<?> handler : getInstances()) {
 			if (handler.supportsSave(data, destination)) {
 				@SuppressWarnings("unchecked")
@@ -77,7 +95,7 @@ default <D> IOPlugin<D> getSaver(final D data, final String destination) {
 	 * The opener to use is automatically determined based on available
 	 * {@link IOPlugin}s; see {@link #getOpener(String)}.
 	 * </p>
-	 * 
+	 *
 	 * @param source The source (e.g., file path) from which to data should be
 	 *          loaded.
 	 * @return An object representing the loaded data, or null if the source is
@@ -86,6 +104,20 @@ default <D> IOPlugin<D> getSaver(final D data, final String destination) {
 	 */
 	Object open(String source) throws IOException;
 
+	/**
+	 * Loads data from the given location.
+	 * <p>
+	 * The opener to use is automatically determined based on available
+	 * {@link IOPlugin}s; see {@link #getOpener(Location)}.
+	 * </p>
+	 *
+	 * @param source The location from which to data should be loaded.
+	 * @return An object representing the loaded data, or null if the source is
+	 *         not supported.
+	 * @throws IOException if something goes wrong loading the data.
+	 */
+	Object open(Location source) throws IOException;
+
 	/**
 	 * Saves data to the given destination. The nature of the destination is left
 	 * intentionally general, but the most common example is a file path.
@@ -93,7 +125,7 @@ default <D> IOPlugin<D> getSaver(final D data, final String destination) {
 	 * The saver to use is automatically determined based on available
 	 * {@link IOPlugin}s; see {@link #getSaver(Object, String)}.
 	 * </p>
-	 * 
+	 *
 	 * @param data The data to be saved to the destination.
 	 * @param destination The destination (e.g., file path) to which data should
 	 *          be saved.
@@ -101,6 +133,19 @@ default <D> IOPlugin<D> getSaver(final D data, final String destination) {
 	 */
 	void save(Object data, String destination) throws IOException;
 
+	/**
+	 * Saves data to the given location.
+	 * <p>
+	 * The saver to use is automatically determined based on available
+	 * {@link IOPlugin}s; see {@link #getSaver(Object, Location)}.
+	 * </p>
+	 * 
+	 * @param data The data to be saved to the destination.
+	 * @param destination The destination location to which data should be saved.
+	 * @throws IOException if something goes wrong saving the data.
+	 */
+	void save(Object data, Location destination) throws IOException;
+
 	// -- HandlerService methods --
 
 	@Override
@@ -110,7 +155,7 @@ default Class<IOPlugin<?>> getPluginType() {
 	}
 
 	@Override
-	default Class<String> getType() {
-		return String.class;
+	default Class<Location> getType() {
+		return Location.class;
 	}
 }
diff --git a/src/main/java/org/scijava/io/event/DataOpenedEvent.java b/src/main/java/org/scijava/io/event/DataOpenedEvent.java
index 7af006c5a..4cf613856 100644
--- a/src/main/java/org/scijava/io/event/DataOpenedEvent.java
+++ b/src/main/java/org/scijava/io/event/DataOpenedEvent.java
@@ -29,22 +29,18 @@
 
 package org.scijava.io.event;
 
+
+import org.scijava.io.location.Location;
+
 /**
- * An event indicating that data has been opened from a source.
+ * An event indicating that data has been opened from a location.
  * 
  * @author Curtis Rueden
  */
 public class DataOpenedEvent extends IOEvent {
 
-	public DataOpenedEvent(final String source, final Object data) {
-		super(source, data);
-	}
-
-	// -- DataOpenedEvent methods --
-
-	/** Gets the source from which data was opened. */
-	public String getSource() {
-		return getDescriptor();
+	public DataOpenedEvent(final Location location, final Object data) {
+		super(location, data);
 	}
 
 }
diff --git a/src/main/java/org/scijava/io/event/DataSavedEvent.java b/src/main/java/org/scijava/io/event/DataSavedEvent.java
index cd6d22439..fe4b7abc3 100644
--- a/src/main/java/org/scijava/io/event/DataSavedEvent.java
+++ b/src/main/java/org/scijava/io/event/DataSavedEvent.java
@@ -29,6 +29,9 @@
 
 package org.scijava.io.event;
 
+
+import org.scijava.io.location.Location;
+
 /**
  * An event indicating that data has been saved to a destination.
  * 
@@ -36,15 +39,8 @@
  */
 public class DataSavedEvent extends IOEvent {
 
-	public DataSavedEvent(final String destination, final Object data) {
+	public DataSavedEvent(final Location destination, final Object data) {
 		super(destination, data);
 	}
 
-	// -- DataSavedEvent methods --
-
-	/** Gets the destination to which data was saved. */
-	public String getDestination() {
-		return getDescriptor();
-	}
-
 }
diff --git a/src/main/java/org/scijava/io/event/IOEvent.java b/src/main/java/org/scijava/io/event/IOEvent.java
index 1a62e6fca..9228c94a4 100644
--- a/src/main/java/org/scijava/io/event/IOEvent.java
+++ b/src/main/java/org/scijava/io/event/IOEvent.java
@@ -30,6 +30,7 @@
 package org.scijava.io.event;
 
 import org.scijava.event.SciJavaEvent;
+import org.scijava.io.location.Location;
 
 /**
  * An event indicating that I/O (e.g., opening or saving) has occurred.
@@ -38,20 +39,20 @@
  */
 public abstract class IOEvent extends SciJavaEvent {
 
-	/** The data descriptor (source or destination). */
-	private final String descriptor;
+	/** The data location (source or destination). */
+	private final Location location;
 
 	/** The data for which I/O took place. */
 	private final Object data;
 
-	public IOEvent(final String descriptor, final Object data) {
-		this.descriptor = descriptor;
+	public IOEvent(final Location location, final Object data) {
+		this.location = location;
 		this.data = data;
 	}
 
-	/** Gets the data descriptor (source or destination). */
-	public String getDescriptor() {
-		return descriptor;
+	/** Gets the data location (source or destination). */
+	public Location getLocation() {
+		return location;
 	}
 
 	/** Gets the data for which I/O took place. */
@@ -63,7 +64,8 @@ public Object getData() {
 
 	@Override
 	public String toString() {
-		return super.toString() + "\n\tdescriptor = " + data + "\n\tdata = " + data;
+		return super.toString() + "\n\tlocation = " + location + "\n\tdata = " +
+			data;
 	}
 
 }
diff --git a/src/main/java/org/scijava/script/io/ScriptIOPlugin.java b/src/main/java/org/scijava/script/io/ScriptIOPlugin.java
index 56d201770..f881dc429 100644
--- a/src/main/java/org/scijava/script/io/ScriptIOPlugin.java
+++ b/src/main/java/org/scijava/script/io/ScriptIOPlugin.java
@@ -33,6 +33,8 @@
 
 import org.scijava.io.AbstractIOPlugin;
 import org.scijava.io.IOPlugin;
+import org.scijava.io.location.FileLocation;
+import org.scijava.io.location.Location;
 import org.scijava.plugin.Parameter;
 import org.scijava.script.ScriptService;
 
@@ -55,13 +57,16 @@ public Class<String> getDataType() {
 	}
 
 	@Override
-	public boolean supportsOpen(final String source) {
+	public boolean supportsOpen(final Location source) {
 		if (scriptService == null) return false; // no service for opening scripts
-		return scriptService.canHandleFile(source);
+		// TODO: Update ScriptService to use Location instead of File.
+		if (!(source instanceof FileLocation)) return false;
+		final FileLocation loc = (FileLocation) source;
+		return scriptService.canHandleFile(loc.getFile());
 	}
 
 	@Override
-	public String open(final String source) throws IOException {
+	public String open(final Location source) throws IOException {
 		if (scriptService == null) return null; // no service for opening scripts
 		// TODO: Use the script service to open the file in the script editor.
 		return null;
diff --git a/src/main/java/org/scijava/text/io/TextIOPlugin.java b/src/main/java/org/scijava/text/io/TextIOPlugin.java
index 3d2c64172..523ff2c34 100644
--- a/src/main/java/org/scijava/text/io/TextIOPlugin.java
+++ b/src/main/java/org/scijava/text/io/TextIOPlugin.java
@@ -29,12 +29,13 @@
 
 package org.scijava.text.io;
 
-import java.io.File;
 import java.io.IOException;
 
 import org.scijava.Priority;
 import org.scijava.io.AbstractIOPlugin;
 import org.scijava.io.IOPlugin;
+import org.scijava.io.location.FileLocation;
+import org.scijava.io.location.Location;
 import org.scijava.plugin.Parameter;
 import org.scijava.plugin.Plugin;
 import org.scijava.text.TextService;
@@ -59,15 +60,19 @@ public Class<String> getDataType() {
 	}
 
 	@Override
-	public boolean supportsOpen(final String source) {
+	public boolean supportsOpen(final Location source) {
 		if (textService == null) return false; // no service for opening text files
-		return textService.supports(new File(source));
+		if (!(source instanceof FileLocation)) return false;
+		final FileLocation loc = (FileLocation) source;
+		return textService.supports(loc.getFile());
 	}
 
 	@Override
-	public String open(final String source) throws IOException {
+	public String open(final Location source) throws IOException {
 		if (textService == null) return null; // no service for opening text files
-		return textService.asHTML(new File(source));
+		if (!(source instanceof FileLocation)) throw new IllegalArgumentException();
+		final FileLocation loc = (FileLocation) source;
+		return textService.asHTML(loc.getFile());
 	}
 
 }
diff --git a/src/main/java/org/scijava/ui/dnd/FileDragAndDropHandler.java b/src/main/java/org/scijava/ui/dnd/FileDragAndDropHandler.java
index 2076c6a20..02289b6e4 100644
--- a/src/main/java/org/scijava/ui/dnd/FileDragAndDropHandler.java
+++ b/src/main/java/org/scijava/ui/dnd/FileDragAndDropHandler.java
@@ -36,6 +36,7 @@
 import org.scijava.display.Display;
 import org.scijava.display.DisplayService;
 import org.scijava.io.IOService;
+import org.scijava.io.location.FileLocation;
 import org.scijava.log.LogService;
 import org.scijava.plugin.Parameter;
 import org.scijava.plugin.Plugin;
@@ -68,7 +69,8 @@ public boolean supports(final File file) {
 		if (!super.supports(file)) return false;
 
 		// verify that the file can be opened somehow
-		return ioService.getOpener(file.getAbsolutePath()) != null;
+		final FileLocation loc = new FileLocation(file);
+		return ioService.getOpener(loc) != null;
 	}
 
 	@Override
@@ -78,13 +80,12 @@ public boolean drop(final File file, final Display<?> display) {
 		if (file == null) return true; // trivial case
 
 		// load the data
-		final String filename = file.getAbsolutePath();
 		final Object data;
 		try {
-			data = ioService.open(filename);
+			data = ioService.open(new FileLocation(file));
 		}
 		catch (final IOException exc) {
-			if (log != null) log.error("Error opening file: " + filename, exc);
+			if (log != null) log.error("Error opening file: " + file, exc);
 			return false;
 		}
 
diff --git a/src/test/java/org/scijava/io/DummyTextFormat.java b/src/test/java/org/scijava/io/DummyTextFormat.java
new file mode 100644
index 000000000..21729843f
--- /dev/null
+++ b/src/test/java/org/scijava/io/DummyTextFormat.java
@@ -0,0 +1,22 @@
+package org.scijava.io;
+
+import org.scijava.plugin.Plugin;
+import org.scijava.text.AbstractTextFormat;
+import org.scijava.text.TextFormat;
+
+import java.util.Collections;
+import java.util.List;
+
+@Plugin(type = TextFormat.class)
+public class DummyTextFormat  extends AbstractTextFormat {
+
+	@Override
+	public List<String> getExtensions() {
+		return Collections.singletonList("txt");
+	}
+
+	@Override
+	public String asHTML(String text) {
+		return text;
+	}
+}
diff --git a/src/test/java/org/scijava/io/IOServiceTest.java b/src/test/java/org/scijava/io/IOServiceTest.java
new file mode 100644
index 000000000..55ebcf774
--- /dev/null
+++ b/src/test/java/org/scijava/io/IOServiceTest.java
@@ -0,0 +1,38 @@
+package org.scijava.io;
+
+import org.junit.Test;
+import org.scijava.Context;
+import org.scijava.io.location.FileLocation;
+import org.scijava.plugin.PluginInfo;
+import org.scijava.text.TextFormat;
+import org.xml.sax.SAXException;
+
+import javax.xml.parsers.ParserConfigurationException;
+import java.io.IOException;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+public class IOServiceTest {
+
+	@Test
+	public void testTextFile() throws IOException {
+		// create context, add dummy text format
+		final Context ctx = new Context();
+		ctx.getPluginIndex().add(new PluginInfo<>(DummyTextFormat.class, TextFormat.class));
+		final IOService io = ctx.getService(IOService.class);
+
+		// open text file from resources as String
+		String localFile = getClass().getResource("test.txt").getPath();
+		Object obj = io.open(localFile);
+		assertNotNull(obj);
+		String content = obj.toString();
+		assertTrue(content.contains("content"));
+
+		// open text file from resources as FileLocation
+		obj = io.open(new FileLocation(localFile));
+		assertNotNull(obj);
+		assertEquals(content, obj.toString());
+	}
+}
diff --git a/src/test/resources/org/scijava/io/test.txt b/src/test/resources/org/scijava/io/test.txt
new file mode 100644
index 000000000..d95f3ad14
--- /dev/null
+++ b/src/test/resources/org/scijava/io/test.txt
@@ -0,0 +1 @@
+content